dont work

This commit is contained in:
Виталий Лавшонок
2025-11-06 15:09:10 +03:00
parent dc6df1480e
commit 1b39b8c77f
6 changed files with 546 additions and 42 deletions

View File

@@ -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={<ArticleEditor />}
/>
<Route
path="/contest/create/*"
element={<ContestEditor />}
/>
<Route path="/article/:articleId" element={<Article />} />
<Route path="*" element={<Home />} />
</Routes>

336
src/pages/ContestEditor.tsx Normal file
View File

@@ -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<string>('');
const query = useQuery();
const back = query.get('back') ?? undefined;
const contestId = Number(query.get('contestId') ?? undefined);
const refactor = !!contestId;
const [contest, setContest] = useState<CreateContestBody>({
name: '',
description: '',
scheduleType: 'AlwaysOpen',
visibility: 'Public',
startsAt: null,
endsAt: null,
attemptDurationMinutes: null,
maxAttempts: null,
allowEarlyFinish: false,
groupId: null,
missionIds: [],
articleIds: [],
});
const [missions, setMissions] = useState<Mission[]>([]);
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 (
<div className="h-screen grid grid-rows-[60px,1fr] text-liquid-white">
<Header backClick={() => navigate(back || '/home/contests')} />
<div className="grid grid-cols-2 h-full min-h-0">
{/* Левая панешь */}
<div className="overflow-y-auto min-h-0 overflow-hidden">
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
<h2 className="text-lg font-semibold mb-3 text-gray-100"></h2>
<div className="">
<div className="font-bold text-[30px] mb-[10px]">
{refactor
? `Редактирвоание контеста #${contestId} \"${contestById?.name}\"`
: 'Создать контест'}
</div>
<Input
name="name"
type="text"
label="Название"
className="mt-[10px]"
placeholder="Введите название"
onChange={(v) => handleChange('name', v)}
defaultState={contest.name ?? ''}
/>
<Input
name="description"
type="text"
label="Описание"
className="mt-[10px]"
placeholder="Введите описание"
onChange={(v) => handleChange('description', v)}
defaultState={contest.description ?? ''}
/>
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<div>
<label className="block text-sm mb-1">
Тип расписания
</label>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={contest.scheduleType}
onChange={(e) =>
handleChange(
'scheduleType',
e.target
.value as CreateContestBody['scheduleType'],
)
}
>
<option value="AlwaysOpen">
Всегда открыт
</option>
<option value="FixedWindow">
Фиксированные даты
</option>
<option value="RollingWindow">
Скользящее окно
</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">
Видимость
</label>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={contest.visibility}
onChange={(e) =>
handleChange(
'visibility',
e.target
.value as CreateContestBody['visibility'],
)
}
>
<option value="Public">
Публичный
</option>
<option value="GroupPrivate">
Групповой
</option>
</select>
</div>
</div>
{/* Даты начала и конца */}
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<DateRangeInput
startValue={contest.startsAt || ''}
endValue={contest.endsAt || ''}
onChange={handleChange}
className="mt-[10px]"
/>
</div>
{/* Продолжительность и лимиты */}
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<Input
name="attemptDurationMinutes"
type="number"
label="Длительность попытки (мин)"
placeholder="Например: 60"
onChange={(v) =>
handleChange(
'attemptDurationMinutes',
Number(v),
)
}
/>
<Input
name="maxAttempts"
type="number"
label="Макс. попыток"
placeholder="Например: 3"
onChange={(v) =>
handleChange('maxAttempts', Number(v))
}
/>
</div>
{/* Разрешить раннее завершение */}
<div className="flex items-center gap-[10px] mt-[15px]">
<input
id="allowEarlyFinish"
type="checkbox"
checked={!!contest.allowEarlyFinish}
onChange={(e) =>
handleChange(
'allowEarlyFinish',
e.target.checked,
)
}
/>
<label htmlFor="allowEarlyFinish">
Разрешить раннее завершение
</label>
</div>
{/* Кнопки */}
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
{refactor ? (
<>
<PrimaryButton
onClick={handleSubmit}
text="Сохранить"
disabled={status === 'loading'}
/>
<ReverseButton
color="error"
onClick={handleSubmit}
text="Удалить"
disabled={status === 'loading'}
/>
</>
) : (
<PrimaryButton
onClick={handleSubmit}
text="Создать"
disabled={status === 'loading'}
/>
)}
</div>
</div>
</div>
</div>
{/* Правая панель */}
<div className="overflow-y-auto min-h-0 overflow-hidden">
<div className="p-4 border-r border-gray-700 flex flex-col h-full">
<h2 className="text-lg font-semibold mb-3 text-gray-100"></h2>
{/* Блок для тегов */}
<div className="mt-[20px] max-w-[600px]">
<div className="grid grid-cols-[1fr,140px] items-end gap-2">
<Input
name="missionId"
autocomplete="missionId"
className="mt-[20px] max-w-[600px]"
type="number"
label="ID миссии"
onChange={(v) => {
setMissionIdInput(v);
}}
defaultState={missionIdInput}
placeholder="458"
onKeyDown={(e) => {
if (e.key == 'Enter') addMission();
}}
/>
<PrimaryButton
onClick={addMission}
text="Добавить"
className="h-[40px] w-[140px]"
/>
</div>
<div className="flex flex-wrap gap-[10px] mt-2">
{missions.map((v, i) => (
<div
key={i}
className="flex items-center gap-1 bg-liquid-lighter px-3 py-1 rounded-full"
>
<span>{v.id}</span>
<span>{v.name}</span>
<button
onClick={() => removeMission(v.id)}
className="text-liquid-red font-bold ml-[5px]"
>
×
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ContestEditor;

View File

@@ -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<Contest>(
`/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<Contest[]>('/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<Contest>) => {
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<number>) => {
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;

View File

@@ -60,7 +60,6 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
id={v.id}
name={v.name}
startAt={v.startsAt}
statusRegister={'reg'}
duration={
new Date(v.endsAt).getTime() -
new Date(v.startsAt).getTime()

View File

@@ -93,7 +93,7 @@ const ContestItem: React.FC<ContestItemProps> = ({
onClick={(e) => {
e.stopPropagation();
navigate(
`/contest/editor?back=/home/account/articles&articleId=${id}`,
`/contest/create?back=/home/account/contests&contestId=${id}`,
);
}}
/>

View File

@@ -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<ModalCreateContestProps> = ({
setActive,
}) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const status = useAppSelector(
(state) => state.contests.createContest.status,
);
@@ -27,21 +32,29 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
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<ModalCreateContestProps> = ({
{/* Кнопки */}
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={handleSubmit}
onClick={() => {
handleSubmit();
}}
text="Создать"
disabled={status === 'loading'}
/>