init + api use

This commit is contained in:
Primakov Alexandr Alexandrovich
2025-11-03 17:59:08 +03:00
commit e777b57991
52 changed files with 20725 additions and 0 deletions

168
src/__data__/api/api.ts Normal file
View File

@@ -0,0 +1,168 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { getConfigValue } from '@brojs/cli'
import { keycloak } from '../kc'
import type {
ChallengeTask,
ChallengeChain,
ChallengeUser,
ChallengeSubmission,
SystemStats,
UserStats,
CreateTaskRequest,
UpdateTaskRequest,
CreateChainRequest,
UpdateChainRequest,
} from '../../types/challenge'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: getConfigValue('challenge-admin.api'),
fetchFn: async (
input: RequestInfo | URL,
init?: RequestInit | undefined,
) => {
const response = await fetch(input, init)
if (response.status === 403) keycloak.login()
return response
},
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
prepareHeaders: (headers) => {
headers.set('Authorization', `Bearer ${keycloak.token}`)
},
}),
tagTypes: ['Task', 'Chain', 'User', 'Submission', 'Stats'],
endpoints: (builder) => ({
// Tasks
getTasks: builder.query<ChallengeTask[], void>({
query: () => '/challenge/tasks',
transformResponse: (response: { data: ChallengeTask[] }) => response.data,
providesTags: ['Task'],
}),
getTask: builder.query<ChallengeTask, string>({
query: (id) => `/challenge/task/${id}`,
transformResponse: (response: { data: ChallengeTask }) => response.data,
providesTags: (_result, _error, id) => [{ type: 'Task', id }],
}),
createTask: builder.mutation<ChallengeTask, CreateTaskRequest>({
query: (body) => ({
url: '/challenge/task',
method: 'POST',
body,
}),
transformResponse: (response: { data: ChallengeTask }) => response.data,
invalidatesTags: ['Task'],
}),
updateTask: builder.mutation<ChallengeTask, { id: string; data: UpdateTaskRequest }>({
query: ({ id, data }) => ({
url: `/challenge/task/${id}`,
method: 'PUT',
body: data,
}),
transformResponse: (response: { data: ChallengeTask }) => response.data,
invalidatesTags: (_result, _error, { id }) => [{ type: 'Task', id }, 'Task'],
}),
deleteTask: builder.mutation<void, string>({
query: (id) => ({
url: `/challenge/task/${id}`,
method: 'DELETE',
}),
invalidatesTags: ['Task', 'Chain'],
}),
// Chains
getChains: builder.query<ChallengeChain[], void>({
query: () => '/challenge/chains',
transformResponse: (response: { data: ChallengeChain[] }) => response.data,
providesTags: ['Chain'],
}),
getChain: builder.query<ChallengeChain, string>({
query: (id) => `/challenge/chain/${id}`,
transformResponse: (response: { data: ChallengeChain }) => response.data,
providesTags: (_result, _error, id) => [{ type: 'Chain', id }],
}),
createChain: builder.mutation<ChallengeChain, CreateChainRequest>({
query: (body) => ({
url: '/challenge/chain',
method: 'POST',
body,
}),
transformResponse: (response: { data: ChallengeChain }) => response.data,
invalidatesTags: ['Chain'],
}),
updateChain: builder.mutation<ChallengeChain, { id: string; data: UpdateChainRequest }>({
query: ({ id, data }) => ({
url: `/challenge/chain/${id}`,
method: 'PUT',
body: data,
}),
transformResponse: (response: { data: ChallengeChain }) => response.data,
invalidatesTags: (_result, _error, { id }) => [{ type: 'Chain', id }, 'Chain'],
}),
deleteChain: builder.mutation<void, string>({
query: (id) => ({
url: `/challenge/chain/${id}`,
method: 'DELETE',
}),
invalidatesTags: ['Chain'],
}),
// Users
getUsers: builder.query<ChallengeUser[], void>({
query: () => '/challenge/users',
transformResponse: (response: { data: ChallengeUser[] }) => response.data,
providesTags: ['User'],
}),
// Statistics
getSystemStats: builder.query<SystemStats, void>({
query: () => '/challenge/stats',
transformResponse: (response: { data: SystemStats }) => response.data,
providesTags: ['Stats'],
}),
getUserStats: builder.query<UserStats, string>({
query: (userId) => `/challenge/user/${userId}/stats`,
transformResponse: (response: { data: UserStats }) => response.data,
providesTags: (_result, _error, userId) => [{ type: 'User', id: userId }],
}),
// Submissions
getUserSubmissions: builder.query<ChallengeSubmission[], { userId: string; taskId?: string }>({
query: ({ userId, taskId }) => {
const params = taskId ? `?taskId=${taskId}` : ''
return `/challenge/user/${userId}/submissions${params}`
},
transformResponse: (response: { data: ChallengeSubmission[] }) => response.data,
providesTags: ['Submission'],
}),
getAllSubmissions: builder.query<ChallengeSubmission[], void>({
query: () => '/challenge/submissions',
transformResponse: (response: { data: ChallengeSubmission[] }) => response.data,
providesTags: ['Submission'],
}),
}),
})
export const {
useGetTasksQuery,
useGetTaskQuery,
useCreateTaskMutation,
useUpdateTaskMutation,
useDeleteTaskMutation,
useGetChainsQuery,
useGetChainQuery,
useCreateChainMutation,
useUpdateChainMutation,
useDeleteChainMutation,
useGetUsersQuery,
useGetSystemStatsQuery,
useGetUserStatsQuery,
useGetUserSubmissionsQuery,
useGetAllSubmissionsQuery,
} = api

8
src/__data__/kc.ts Normal file
View File

@@ -0,0 +1,8 @@
import Keycloak from 'keycloak-js'
export const keycloak = new Keycloak({
url: KC_URL,
realm: KC_REALM,
clientId: KC_CLIENT_ID,
});

View File

@@ -0,0 +1,10 @@
import { createSlice } from '@reduxjs/toolkit'
import { UserData } from '../types'
export const userSlice = createSlice({
name: 'user',
initialState: null as UserData,
reducers: {
}
})

24
src/__data__/store.ts Normal file
View File

@@ -0,0 +1,24 @@
import { configureStore } from '@reduxjs/toolkit'
import { TypedUseSelectorHook, useSelector } from 'react-redux'
import { api } from './api/api'
import { userSlice } from './slices/user'
export const createStore = (preloadedState = {}) =>
configureStore({
preloadedState,
reducer: {
[api.reducerPath]: api.reducer,
user: userSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
immutableCheck: false,
serializableCheck: false,
}).concat(api.middleware),
})
export type Store = ReturnType<ReturnType<typeof createStore>['getState']>
export const useAppSelector: TypedUseSelectorHook<Store> = useSelector

36
src/__data__/urls.ts Normal file
View File

@@ -0,0 +1,36 @@
import { getNavigation, getNavigationValue } from '@brojs/cli'
import pkg from '../../package.json'
const baseUrl = getNavigationValue(`${pkg.name}.main`)
const navs = getNavigation()
const makeUrl = (url: string) => baseUrl + url
export const URLs = {
baseUrl,
// Dashboard
dashboard: makeUrl(''),
// Tasks
tasks: makeUrl('/tasks'),
taskNew: makeUrl('/tasks/new'),
taskEdit: (id: string) => makeUrl(`/tasks/${id}`),
taskEditPath: makeUrl('/tasks/:id'),
// Chains
chains: makeUrl('/chains'),
chainNew: makeUrl('/chains/new'),
chainEdit: (id: string) => makeUrl(`/chains/${id}`),
chainEditPath: makeUrl('/chains/:id'),
// Users
users: makeUrl('/users'),
// Submissions
submissions: makeUrl('/submissions'),
// External links
challengePlayer: navs['link.challenge'] || '/challenge',
}

26
src/app.tsx Normal file
View File

@@ -0,0 +1,26 @@
import React from 'react'
import { BrowserRouter } from 'react-router-dom'
import { Dashboard } from './dashboard'
import { Provider } from './theme'
import { Provider as ReduxProvider } from 'react-redux'
import { Toaster } from './components/ui/toaster'
import type { PropsWithChildren } from 'react'
const App = ({ store }: PropsWithChildren<{ store?: any }>) => {
if (!store) {
return <div>Loading...</div>
}
return (
<ReduxProvider store={store}>
<Provider>
<BrowserRouter>
<Dashboard />
</BrowserRouter>
<Toaster />
</Provider>
</ReduxProvider>
)
}
export default App

View File

@@ -0,0 +1,61 @@
import React from 'react'
import {
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
} from '@chakra-ui/react'
import { Button } from '@chakra-ui/react'
interface ConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
message: string
confirmLabel?: string
cancelLabel?: string
isLoading?: boolean
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
isLoading = false,
}) => {
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<DialogBody>
{message}
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{cancelLabel}
</Button>
</DialogActionTrigger>
<Button
colorPalette="red"
onClick={onConfirm}
loading={isLoading}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import { Box, Text, VStack, Button } from '@chakra-ui/react'
interface EmptyStateProps {
title: string
description?: string
actionLabel?: string
onAction?: () => void
}
export const EmptyState: React.FC<EmptyStateProps> = ({
title,
description,
actionLabel,
onAction,
}) => {
return (
<Box
bg="white"
borderRadius="lg"
borderWidth="2px"
borderColor="gray.200"
borderStyle="dashed"
p={12}
textAlign="center"
>
<VStack gap={4}>
<Text fontSize="4xl">📭</Text>
<Text fontSize="lg" fontWeight="semibold" color="gray.700">
{title}
</Text>
{description && (
<Text color="gray.600" fontSize="sm">
{description}
</Text>
)}
{actionLabel && onAction && (
<Button colorPalette="teal" onClick={onAction} mt={2}>
{actionLabel}
</Button>
)}
</VStack>
</Box>
)
}

View File

@@ -0,0 +1,33 @@
import React from 'react'
import { Box, Text, Button } from '@chakra-ui/react'
interface ErrorAlertProps {
message?: string
onRetry?: () => void
}
export const ErrorAlert: React.FC<ErrorAlertProps> = ({
message = 'Произошла ошибка при загрузке данных',
onRetry,
}) => {
return (
<Box
bg="red.50"
borderWidth="1px"
borderColor="red.200"
borderRadius="lg"
p={6}
textAlign="center"
>
<Text color="red.700" fontWeight="medium" mb={4}>
{message}
</Text>
{onRetry && (
<Button colorPalette="red" size="sm" onClick={onRetry}>
Попробовать снова
</Button>
)}
</Box>
)
}

103
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,103 @@
import React from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Box, Container, Flex, HStack, VStack, Button, Text } from '@chakra-ui/react'
import { useAppSelector } from '../__data__/store'
import { URLs } from '../__data__/urls'
import { keycloak } from '../__data__/kc'
interface LayoutProps {
children: React.ReactNode
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const location = useLocation()
const navigate = useNavigate()
const user = useAppSelector((state) => state.user)
const handleLogout = () => {
keycloak.logout()
}
const handleNavigateToPlayer = () => {
navigate(URLs.challengePlayer)
}
const isActive = (path: string) => {
return location.pathname === path
}
const navItems = [
{ label: 'Dashboard', path: URLs.dashboard },
{ label: 'Задания', path: URLs.tasks },
{ label: 'Цепочки', path: URLs.chains },
{ label: 'Пользователи', path: URLs.users },
{ label: 'Попытки', path: URLs.submissions },
]
return (
<Box minH="100vh" bg="gray.50">
{/* Header */}
<Box bg="white" borderBottom="1px" borderColor="gray.200" position="sticky" top={0} zIndex={10}>
<Container maxW="container.xl">
<Flex h="16" alignItems="center" justifyContent="space-between">
<Text fontSize="xl" fontWeight="bold" color="teal.600">
Challenge Admin
</Text>
<HStack gap={4}>
<Button
size="sm"
variant="ghost"
onClick={handleNavigateToPlayer}
>
Открыть проигрыватель
</Button>
{user && (
<HStack gap={2}>
<Text fontSize="sm" color="gray.600">
{user.preferred_username || user.email}
</Text>
<Button
size="sm"
colorPalette="red"
variant="ghost"
onClick={handleLogout}
>
Выйти
</Button>
</HStack>
)}
</HStack>
</Flex>
</Container>
</Box>
{/* Navigation */}
<Box bg="white" borderBottom="1px" borderColor="gray.200">
<Container maxW="container.xl">
<HStack gap={1} py={2}>
{navItems.map((item) => (
<Button
key={item.path}
as={Link}
to={item.path}
size="sm"
variant={isActive(item.path) ? 'solid' : 'ghost'}
colorPalette={isActive(item.path) ? 'teal' : 'gray'}
>
{item.label}
</Button>
))}
</HStack>
</Container>
</Box>
{/* Main Content */}
<Container maxW="container.xl" py={8}>
{children}
</Container>
</Box>
)
}

View File

@@ -0,0 +1,18 @@
import React from 'react'
import { Flex, Spinner, Text, VStack } from '@chakra-ui/react'
interface LoadingSpinnerProps {
message?: string
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ message = 'Загрузка...' }) => {
return (
<Flex justify="center" align="center" minH="400px">
<VStack gap={4}>
<Spinner size="xl" color="teal.500" />
<Text color="gray.600">{message}</Text>
</VStack>
</Flex>
)
}

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { Box, Text, Flex, Icon } from '@chakra-ui/react'
interface StatCardProps {
label: string
value: string | number
icon?: React.ReactElement
colorScheme?: string
}
export const StatCard: React.FC<StatCardProps> = ({ label, value, icon, colorScheme = 'teal' }) => {
return (
<Box
bg="white"
p={6}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Flex align="center" justify="space-between" mb={2}>
<Text fontSize="sm" color="gray.600" fontWeight="medium">
{label}
</Text>
{icon && (
<Box color={`${colorScheme}.500`}>
{icon}
</Box>
)}
</Flex>
<Text fontSize="3xl" fontWeight="bold" color={`${colorScheme}.600`}>
{value}
</Text>
</Box>
)
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import { Badge } from '@chakra-ui/react'
import type { SubmissionStatus } from '../types/challenge'
interface StatusBadgeProps {
status: SubmissionStatus
}
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const getColorPalette = () => {
switch (status) {
case 'accepted':
return 'green'
case 'needs_revision':
return 'red'
case 'in_progress':
return 'blue'
case 'pending':
return 'orange'
default:
return 'gray'
}
}
const getLabel = () => {
switch (status) {
case 'accepted':
return 'Принято'
case 'needs_revision':
return 'Доработка'
case 'in_progress':
return 'Проверяется'
case 'pending':
return 'Ожидает'
default:
return status
}
}
return (
<Badge colorPalette={getColorPalette()} variant="subtle">
{getLabel()}
</Badge>
)
}

View File

@@ -0,0 +1,12 @@
import React from 'react'
import { createToaster, Toaster as ChakraToaster } from '@chakra-ui/react'
export const toaster = createToaster({
placement: 'top-end',
duration: 3000,
})
export const Toaster = () => {
return <ChakraToaster toaster={toaster} />
}

107
src/dashboard.tsx Normal file
View File

@@ -0,0 +1,107 @@
import React, { Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { Layout } from './components/Layout'
import { DashboardPage } from './pages/dashboard/DashboardPage'
import { TasksListPage } from './pages/tasks/TasksListPage'
import { TaskFormPage } from './pages/tasks/TaskFormPage'
import { ChainsListPage } from './pages/chains/ChainsListPage'
import { ChainFormPage } from './pages/chains/ChainFormPage'
import { UsersPage } from './pages/users/UsersPage'
import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
import { URLs } from './__data__/urls'
const PageWrapper = ({ children }: React.PropsWithChildren) => (
<Suspense fallback={<div>Loading...</div>}>
<Layout>{children}</Layout>
</Suspense>
)
export const Dashboard = () => {
return (
<Routes>
{/* Dashboard */}
<Route
path={URLs.dashboard}
element={
<PageWrapper>
<DashboardPage />
</PageWrapper>
}
/>
{/* Tasks */}
<Route
path={URLs.tasks}
element={
<PageWrapper>
<TasksListPage />
</PageWrapper>
}
/>
<Route
path={URLs.taskNew}
element={
<PageWrapper>
<TaskFormPage />
</PageWrapper>
}
/>
<Route
path={URLs.taskEditPath}
element={
<PageWrapper>
<TaskFormPage />
</PageWrapper>
}
/>
{/* Chains */}
<Route
path={URLs.chains}
element={
<PageWrapper>
<ChainsListPage />
</PageWrapper>
}
/>
<Route
path={URLs.chainNew}
element={
<PageWrapper>
<ChainFormPage />
</PageWrapper>
}
/>
<Route
path={URLs.chainEditPath}
element={
<PageWrapper>
<ChainFormPage />
</PageWrapper>
}
/>
{/* Users */}
<Route
path={URLs.users}
element={
<PageWrapper>
<UsersPage />
</PageWrapper>
}
/>
{/* Submissions */}
<Route
path={URLs.submissions}
element={
<PageWrapper>
<SubmissionsPage />
</PageWrapper>
}
/>
</Routes>
)
}

68
src/index.tsx Normal file
View File

@@ -0,0 +1,68 @@
/* eslint-disable react/display-name */
import React from 'react'
import ReactDOM from 'react-dom/client'
import i18next from 'i18next'
import { i18nextReactInitConfig } from '@brojs/cli'
import App from './app'
import { keycloak } from './__data__/kc'
import { isAuthLoopBlocked, recordAuthAttempt, clearAuthAttempts } from './utils/authLoopGuard'
import { createStore } from './__data__/store'
i18next.t = i18next.t.bind(i18next)
const i18nextPromise = i18nextReactInitConfig(i18next)
export default (props) => <App {...props} />
let rootElement: ReactDOM.Root
export const mount = async (Component, element = document.getElementById('app')) => {
let user = null
try {
if (isAuthLoopBlocked()) {
await i18nextPromise
rootElement = ReactDOM.createRoot(element)
rootElement.render(<button onClick={() => keycloak.login()} style={{
width: '100%',
height: '100%',
backgroundColor: 'red',
margin: 'auto'
}}>Login</button>)
return
}
recordAuthAttempt()
await keycloak.init({
onLoad: 'login-required'
})
const userInfo = await keycloak.loadUserInfo()
if (userInfo && keycloak.tokenParsed) {
user = { ...userInfo, ...keycloak.tokenParsed }
} else {
console.error('No userInfo or tokenParsed', userInfo, keycloak.tokenParsed)
}
clearAuthAttempts()
} catch (error) {
console.error('Failed to initialize Keycloak:', error)
}
const store = createStore({ user })
await i18nextPromise
rootElement = ReactDOM.createRoot(element)
rootElement.render(<Component store={store} />)
if(module.hot) {
module.hot.accept('./app', ()=> {
rootElement.render(<Component store={store} />)
})
}
}
export const unmount = () => {
rootElement.unmount()
}

View File

@@ -0,0 +1,320 @@
import React, { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Box,
Heading,
Button,
Input,
VStack,
HStack,
Text,
Field,
Badge,
IconButton,
Flex,
} from '@chakra-ui/react'
import {
useGetChainQuery,
useGetTasksQuery,
useCreateChainMutation,
useUpdateChainMutation,
} from '../../__data__/api/api'
import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { toaster } from '../../components/ui/toaster'
import type { ChallengeTask } from '../../types/challenge'
export const ChainFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEdit = !!id
const { data: chain, isLoading: isLoadingChain, error: loadError } = useGetChainQuery(id!, {
skip: !id,
})
const { data: allTasks, isLoading: isLoadingTasks } = useGetTasksQuery()
const [createChain, { isLoading: isCreating }] = useCreateChainMutation()
const [updateChain, { isLoading: isUpdating }] = useUpdateChainMutation()
const [name, setName] = useState('')
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
if (chain) {
setName(chain.name)
setSelectedTasks(chain.tasks)
}
}, [chain])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
toaster.create({
title: 'Ошибка валидации',
description: 'Введите название цепочки',
type: 'error',
})
return
}
if (selectedTasks.length === 0) {
toaster.create({
title: 'Ошибка валидации',
description: 'Добавьте хотя бы одно задание',
type: 'error',
})
return
}
try {
const taskIds = selectedTasks.map((task) => task.id)
if (isEdit && id) {
await updateChain({
id,
data: {
name: name.trim(),
tasks: taskIds,
},
}).unwrap()
toaster.create({
title: 'Успешно',
description: 'Цепочка обновлена',
type: 'success',
})
} else {
await createChain({
name: name.trim(),
tasks: taskIds,
}).unwrap()
toaster.create({
title: 'Успешно',
description: 'Цепочка создана',
type: 'success',
})
}
navigate(URLs.chains)
} catch (err: any) {
toaster.create({
title: 'Ошибка',
description: err?.data?.error?.message || 'Не удалось сохранить цепочку',
type: 'error',
})
}
}
const handleAddTask = (task: ChallengeTask) => {
if (!selectedTasks.find((t) => t.id === task.id)) {
setSelectedTasks([...selectedTasks, task])
}
}
const handleRemoveTask = (taskId: string) => {
setSelectedTasks(selectedTasks.filter((t) => t.id !== taskId))
}
const handleMoveUp = (index: number) => {
if (index === 0) return
const newTasks = [...selectedTasks]
;[newTasks[index - 1], newTasks[index]] = [newTasks[index], newTasks[index - 1]]
setSelectedTasks(newTasks)
}
const handleMoveDown = (index: number) => {
if (index === selectedTasks.length - 1) return
const newTasks = [...selectedTasks]
;[newTasks[index], newTasks[index + 1]] = [newTasks[index + 1], newTasks[index]]
setSelectedTasks(newTasks)
}
if (isEdit && isLoadingChain) {
return <LoadingSpinner message="Загрузка цепочки..." />
}
if (isEdit && loadError) {
return <ErrorAlert message="Не удалось загрузить цепочку" />
}
if (isLoadingTasks) {
return <LoadingSpinner message="Загрузка заданий..." />
}
if (!allTasks) {
return <ErrorAlert message="Не удалось загрузить список заданий" />
}
const isLoading = isCreating || isUpdating
const availableTasks = allTasks.filter(
(task) =>
!selectedTasks.find((t) => t.id === task.id) &&
task.title.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<Box>
<Heading mb={6}>{isEdit ? 'Редактировать цепочку' : 'Создать цепочку'}</Heading>
<Box
as="form"
onSubmit={handleSubmit}
bg="white"
p={6}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<VStack gap={6} align="stretch">
{/* Name */}
<Field.Root required>
<Field.Label>Название цепочки</Field.Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Введите название цепочки"
maxLength={255}
disabled={isLoading}
/>
</Field.Root>
{/* Selected Tasks */}
<Box>
<Text fontWeight="bold" mb={3}>
Задания в цепочке ({selectedTasks.length})
</Text>
{selectedTasks.length === 0 ? (
<Box
p={6}
borderWidth="2px"
borderStyle="dashed"
borderColor="gray.200"
borderRadius="md"
textAlign="center"
>
<Text color="gray.500">Добавьте задания из списка ниже</Text>
</Box>
) : (
<VStack gap={2} align="stretch">
{selectedTasks.map((task, index) => (
<Flex
key={task.id}
p={3}
bg="teal.50"
borderWidth="1px"
borderColor="teal.200"
borderRadius="md"
align="center"
justify="space-between"
>
<HStack gap={3} flex={1}>
<Badge colorPalette="teal" variant="solid">
#{index + 1}
</Badge>
<Text fontWeight="medium">{task.title}</Text>
</HStack>
<HStack gap={1}>
<IconButton
size="sm"
variant="ghost"
onClick={() => handleMoveUp(index)}
disabled={index === 0 || isLoading}
aria-label="Move up"
>
</IconButton>
<IconButton
size="sm"
variant="ghost"
onClick={() => handleMoveDown(index)}
disabled={index === selectedTasks.length - 1 || isLoading}
aria-label="Move down"
>
</IconButton>
<IconButton
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => handleRemoveTask(task.id)}
disabled={isLoading}
aria-label="Remove"
>
</IconButton>
</HStack>
</Flex>
))}
</VStack>
)}
</Box>
{/* Available Tasks */}
<Box>
<Text fontWeight="bold" mb={3}>
Доступные задания
</Text>
<Input
placeholder="Поиск заданий..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
mb={3}
/>
{availableTasks.length === 0 ? (
<Box
p={6}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
textAlign="center"
>
<Text color="gray.500">
{allTasks.length === selectedTasks.length
? 'Все задания уже добавлены'
: 'Ничего не найдено'}
</Text>
</Box>
) : (
<VStack gap={2} align="stretch" maxH="400px" overflowY="auto">
{availableTasks.map((task) => (
<Flex
key={task.id}
p={3}
bg="gray.50"
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
align="center"
justify="space-between"
cursor="pointer"
_hover={{ bg: 'gray.100' }}
onClick={() => handleAddTask(task)}
>
<Text>{task.title}</Text>
<Button size="sm" colorPalette="teal" variant="ghost">
+ Добавить
</Button>
</Flex>
))}
</VStack>
)}
</Box>
{/* Actions */}
<HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.chains)} disabled={isLoading}>
Отмена
</Button>
<Button type="submit" colorPalette="teal" loading={isLoading}>
{isEdit ? 'Сохранить изменения' : 'Создать цепочку'}
</Button>
</HStack>
</VStack>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Heading,
Button,
Table,
Flex,
Input,
HStack,
Text,
Badge,
} from '@chakra-ui/react'
import { useGetChainsQuery, useDeleteChainMutation } from '../../__data__/api/api'
import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import type { ChallengeChain } from '../../types/challenge'
import { toaster } from '../../components/ui/toaster'
export const ChainsListPage: React.FC = () => {
const navigate = useNavigate()
const { data: chains, isLoading, error, refetch } = useGetChainsQuery()
const [deleteChain, { isLoading: isDeleting }] = useDeleteChainMutation()
const [searchQuery, setSearchQuery] = useState('')
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
const handleDeleteChain = async () => {
if (!chainToDelete) return
try {
await deleteChain(chainToDelete.id).unwrap()
toaster.create({
title: 'Успешно',
description: 'Цепочка удалена',
type: 'success',
})
setChainToDelete(null)
} catch (err) {
toaster.create({
title: 'Ошибка',
description: 'Не удалось удалить цепочку',
type: 'error',
})
}
}
if (isLoading) {
return <LoadingSpinner message="Загрузка цепочек..." />
}
if (error || !chains) {
return <ErrorAlert message="Не удалось загрузить список цепочек" onRetry={refetch} />
}
const filteredChains = chains.filter((chain) =>
chain.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<Box>
<Flex justify="space-between" align="center" mb={6}>
<Heading>Цепочки заданий</Heading>
<Button colorPalette="teal" onClick={() => navigate(URLs.chainNew)}>
+ Создать цепочку
</Button>
</Flex>
{chains.length > 0 && (
<Box mb={4}>
<Input
placeholder="Поиск по названию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
/>
</Box>
)}
{filteredChains.length === 0 && chains.length === 0 ? (
<EmptyState
title="Нет цепочек"
description="Создайте первую цепочку заданий"
actionLabel="Создать цепочку"
onAction={() => navigate(URLs.chainNew)}
/>
) : filteredChains.length === 0 ? (
<EmptyState
title="Ничего не найдено"
description={`По запросу "${searchQuery}" ничего не найдено`}
/>
) : (
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm">
<Table.Header>
<Table.Row>
<Table.ColumnHeader>Название</Table.ColumnHeader>
<Table.ColumnHeader>Количество заданий</Table.ColumnHeader>
<Table.ColumnHeader>Дата создания</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{filteredChains.map((chain) => (
<Table.Row key={chain.id}>
<Table.Cell fontWeight="medium">{chain.name}</Table.Cell>
<Table.Cell>
<Badge colorPalette="teal" variant="subtle">
{chain.tasks.length} заданий
</Badge>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{formatDate(chain.createdAt)}
</Text>
</Table.Cell>
<Table.Cell textAlign="right">
<HStack gap={2} justify="flex-end">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(URLs.chainEdit(chain.id))}
>
Редактировать
</Button>
<Button
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => setChainToDelete(chain)}
>
Удалить
</Button>
</HStack>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)}
<ConfirmDialog
isOpen={!!chainToDelete}
onClose={() => setChainToDelete(null)}
onConfirm={handleDeleteChain}
title="Удалить цепочку"
message={`Вы уверены, что хотите удалить цепочку "${chainToDelete?.name}"? Это действие нельзя отменить.`}
confirmLabel="Удалить"
isLoading={isDeleting}
/>
</Box>
)
}

View File

@@ -0,0 +1,162 @@
import React from 'react'
import { Box, Heading, Grid, Text, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
import { useGetSystemStatsQuery } from '../../__data__/api/api'
import { StatCard } from '../../components/StatCard'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
export const DashboardPage: React.FC = () => {
const { data: stats, isLoading, error, refetch } = useGetSystemStatsQuery(undefined, {
pollingInterval: 10000, // Обновление каждые 10 секунд
})
if (isLoading) {
return <LoadingSpinner message="Загрузка статистики..." />
}
if (error || !stats) {
return <ErrorAlert message="Не удалось загрузить статистику системы" onRetry={refetch} />
}
const acceptanceRate = stats.submissions.total > 0
? ((stats.submissions.accepted / stats.submissions.total) * 100).toFixed(1)
: '0'
const rejectionRate = stats.submissions.total > 0
? ((stats.submissions.rejected / stats.submissions.total) * 100).toFixed(1)
: '0'
const queueUtilization = stats.queue.maxConcurrency > 0
? ((stats.queue.currentlyProcessing / stats.queue.maxConcurrency) * 100).toFixed(0)
: '0'
return (
<Box>
<Heading mb={6}>Dashboard</Heading>
{/* Main Stats */}
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={8}>
<StatCard label="Всего пользователей" value={stats.users} colorScheme="blue" />
<StatCard label="Всего заданий" value={stats.tasks} colorScheme="teal" />
<StatCard label="Всего цепочек" value={stats.chains} colorScheme="purple" />
<StatCard label="Всего проверок" value={stats.submissions.total} colorScheme="orange" />
</Grid>
{/* Submissions Stats */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
<Heading size="md" mb={4}>
Статистика проверок
</Heading>
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={6}>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Принято
</Text>
<HStack>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.submissions.accepted}
</Text>
<Badge colorPalette="green">{acceptanceRate}%</Badge>
</HStack>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Отклонено
</Text>
<HStack>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.submissions.rejected}
</Text>
<Badge colorPalette="red">{rejectionRate}%</Badge>
</HStack>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Ожидают
</Text>
<Text fontSize="2xl" fontWeight="bold" color="yellow.600">
{stats.submissions.pending}
</Text>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
В процессе
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.submissions.inProgress}
</Text>
</VStack>
</Grid>
</Box>
{/* Queue Stats */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
<Heading size="md" mb={4}>
Статус очереди
</Heading>
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={4}>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
В обработке
</Text>
<HStack align="baseline">
<Text fontSize="2xl" fontWeight="bold" color="teal.600">
{stats.queue.currentlyProcessing}
</Text>
<Text fontSize="sm" color="gray.500">
/ {stats.queue.maxConcurrency}
</Text>
</HStack>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Ожидают в очереди
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.queue.waiting}
</Text>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Всего в очереди
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.queue.queueLength}
</Text>
</VStack>
</Grid>
<Box>
<Text fontSize="sm" color="gray.600" mb={2}>
Загруженность очереди: {queueUtilization}%
</Text>
<Progress.Root value={Number(queueUtilization)} colorPalette="teal" size="sm" borderRadius="full">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box>
</Box>
{/* Average Check Time */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={2}>
Среднее время проверки
</Heading>
<Text fontSize="3xl" fontWeight="bold" color="purple.600">
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек
</Text>
<Text fontSize="sm" color="gray.600" mt={2}>
Время от отправки решения до получения результата
</Text>
</Box>
</Box>
)
}

4
src/pages/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { lazy } from 'react'
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))

2
src/pages/main/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { MainPage as default } from './main'

11
src/pages/main/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react'
export const MainPage = () => {
return (
<div>
<h1>Главная страница проекта challenge-admin-pl</h1>
<p>Это базовая страница с React Router</p>
</div>
)
}

View File

@@ -0,0 +1,340 @@
import React, { useState } from 'react'
import {
Box,
Heading,
Table,
Input,
Text,
Button,
HStack,
VStack,
Select,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
createListCollection,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import { useGetAllSubmissionsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState'
import { StatusBadge } from '../../components/StatusBadge'
import type { ChallengeSubmission, SubmissionStatus, ChallengeTask, ChallengeUser } from '../../types/challenge'
export const SubmissionsPage: React.FC = () => {
const { data: submissions, isLoading, error, refetch } = useGetAllSubmissionsQuery()
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all')
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
if (isLoading) {
return <LoadingSpinner message="Загрузка попыток..." />
}
if (error || !submissions) {
return <ErrorAlert message="Не удалось загрузить список попыток" onRetry={refetch} />
}
const filteredSubmissions = submissions.filter((submission) => {
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
const matchesSearch =
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === 'all' || submission.status === statusFilter
return matchesSearch && matchesStatus
})
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const getCheckTime = (submission: ChallengeSubmission) => {
if (!submission.checkedAt) return '—'
const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime()
const diff = Math.round((checked - submitted) / 1000)
return `${diff} сек`
}
const statusOptions = createListCollection({
items: [
{ label: 'Все статусы', value: 'all' },
{ label: 'Принято', value: 'accepted' },
{ label: 'Доработка', value: 'needs_revision' },
{ label: 'Проверяется', value: 'in_progress' },
{ label: 'Ожидает', value: 'pending' },
],
})
return (
<Box>
<Heading mb={6}>Попытки решений</Heading>
{/* Filters */}
{submissions.length > 0 && (
<HStack mb={4} gap={4}>
<Input
placeholder="Поиск по пользователю или заданию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
/>
<Select.Root
collection={statusOptions}
value={[statusFilter]}
onValueChange={(e) => setStatusFilter(e.value[0] as SubmissionStatus | 'all')}
maxW="200px"
>
<Select.Trigger>
<Select.ValueText placeholder="Статус" />
</Select.Trigger>
<Select.Content>
{statusOptions.items.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
</HStack>
)}
{filteredSubmissions.length === 0 && submissions.length === 0 ? (
<EmptyState title="Нет попыток" description="Попытки появятся после отправки решений" />
) : filteredSubmissions.length === 0 ? (
<EmptyState title="Ничего не найдено" description="Попробуйте изменить фильтры" />
) : (
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm">
<Table.Header>
<Table.Row>
<Table.ColumnHeader>Пользователь</Table.ColumnHeader>
<Table.ColumnHeader>Задание</Table.ColumnHeader>
<Table.ColumnHeader>Статус</Table.ColumnHeader>
<Table.ColumnHeader>Попытка</Table.ColumnHeader>
<Table.ColumnHeader>Дата отправки</Table.ColumnHeader>
<Table.ColumnHeader>Время проверки</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{filteredSubmissions.map((submission) => {
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
return (
<Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>{task.title}</Table.Cell>
<Table.Cell>
<StatusBadge status={submission.status} />
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
#{submission.attemptNumber}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{formatDate(submission.submittedAt)}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{getCheckTime(submission)}
</Text>
</Table.Cell>
<Table.Cell textAlign="right">
<Button
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => setSelectedSubmission(submission)}
>
Детали
</Button>
</Table.Cell>
</Table.Row>
)
})}
</Table.Body>
</Table.Root>
</Box>
)}
{/* Submission Details Modal */}
<SubmissionDetailsModal
submission={selectedSubmission}
isOpen={!!selectedSubmission}
onClose={() => setSelectedSubmission(null)}
/>
</Box>
)
}
interface SubmissionDetailsModalProps {
submission: ChallengeSubmission | null
isOpen: boolean
onClose: () => void
}
const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
submission,
isOpen,
onClose,
}) => {
if (!submission) return null
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const getCheckTime = () => {
if (!submission.checkedAt) return null
const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime()
return ((checked - submitted) / 1000).toFixed(2)
}
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>Детали попытки #{submission.attemptNumber}</DialogTitle>
</DialogHeader>
<DialogBody>
<VStack gap={6} align="stretch">
{/* Meta */}
<Box>
<HStack mb={4} justify="space-between">
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
Пользователь
</Text>
<Text fontWeight="bold">{user.nickname}</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
Статус
</Text>
<StatusBadge status={submission.status} />
</Box>
</HStack>
<VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600">
<strong>Отправлено:</strong> {formatDate(submission.submittedAt)}
</Text>
{submission.checkedAt && (
<>
<Text fontSize="sm" color="gray.600">
<strong>Проверено:</strong> {formatDate(submission.checkedAt)}
</Text>
<Text fontSize="sm" color="gray.600">
<strong>Время проверки:</strong> {getCheckTime()} сек
</Text>
</>
)}
</VStack>
</Box>
{/* Task */}
<Box>
<Text fontWeight="bold" mb={2}>
Задание: {task.title}
</Text>
<Box
p={4}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
maxH="200px"
overflowY="auto"
>
<ReactMarkdown>{task.description}</ReactMarkdown>
</Box>
</Box>
{/* Solution */}
<Box>
<Text fontWeight="bold" mb={2}>
Решение пользователя:
</Text>
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
maxH="300px"
overflowY="auto"
>
<Text
fontFamily="monospace"
fontSize="sm"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{submission.result}
</Text>
</Box>
</Box>
{/* Feedback */}
{submission.feedback && (
<Box>
<Text fontWeight="bold" mb={2}>
Обратная связь от LLM:
</Text>
<Box
p={4}
bg={submission.status === 'accepted' ? 'green.50' : 'red.50'}
borderRadius="md"
borderWidth="1px"
borderColor={submission.status === 'accepted' ? 'green.200' : 'red.200'}
>
<Text>{submission.feedback}</Text>
</Box>
</Box>
)}
</VStack>
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
Закрыть
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}

View File

@@ -0,0 +1,244 @@
import React, { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Box,
Heading,
Button,
Input,
Textarea,
VStack,
HStack,
Text,
Flex,
Stack,
Field,
Tabs,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import {
useGetTaskQuery,
useCreateTaskMutation,
useUpdateTaskMutation,
} from '../../__data__/api/api'
import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { toaster } from '../../components/ui/toaster'
export const TaskFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEdit = !!id
const { data: task, isLoading: isLoadingTask, error: loadError } = useGetTaskQuery(id!, {
skip: !id,
})
const [createTask, { isLoading: isCreating }] = useCreateTaskMutation()
const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [hiddenInstructions, setHiddenInstructions] = useState('')
const [showDescPreview, setShowDescPreview] = useState(false)
useEffect(() => {
if (task) {
setTitle(task.title)
setDescription(task.description)
setHiddenInstructions(task.hiddenInstructions || '')
}
}, [task])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) {
toaster.create({
title: 'Ошибка валидации',
description: 'Заполните обязательные поля',
type: 'error',
})
return
}
try {
if (isEdit && id) {
await updateTask({
id,
data: {
title: title.trim(),
description: description.trim(),
hiddenInstructions: hiddenInstructions.trim() || undefined,
},
}).unwrap()
toaster.create({
title: 'Успешно',
description: 'Задание обновлено',
type: 'success',
})
} else {
await createTask({
title: title.trim(),
description: description.trim(),
hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap()
toaster.create({
title: 'Успешно',
description: 'Задание создано',
type: 'success',
})
}
navigate(URLs.tasks)
} catch (err: any) {
toaster.create({
title: 'Ошибка',
description: err?.data?.error?.message || 'Не удалось сохранить задание',
type: 'error',
})
}
}
if (isEdit && isLoadingTask) {
return <LoadingSpinner message="Загрузка задания..." />
}
if (isEdit && loadError) {
return <ErrorAlert message="Не удалось загрузить задание" />
}
const isLoading = isCreating || isUpdating
return (
<Box>
<Heading mb={6}>{isEdit ? 'Редактировать задание' : 'Создать задание'}</Heading>
<Box
as="form"
onSubmit={handleSubmit}
bg="white"
p={6}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<VStack gap={6} align="stretch">
{/* Title */}
<Field.Root required>
<Field.Label>Название задания</Field.Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Введите название задания"
maxLength={255}
disabled={isLoading}
/>
<Field.HelperText>Максимум 255 символов</Field.HelperText>
</Field.Root>
{/* Description with Markdown */}
<Field.Root required>
<Field.Label>Описание (Markdown)</Field.Label>
<Tabs.Root
value={showDescPreview ? 'preview' : 'editor'}
onValueChange={(e) => setShowDescPreview(e.value === 'preview')}
>
<Tabs.List>
<Tabs.Trigger value="editor">Редактор</Tabs.Trigger>
<Tabs.Trigger value="preview">Превью</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="editor" pt={4}>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="# Заголовок задания&#10;&#10;Описание задания в формате Markdown..."
rows={15}
fontFamily="monospace"
disabled={isLoading}
/>
</Tabs.Content>
<Tabs.Content value="preview" pt={4}>
<Box
p={4}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
minH="300px"
bg="gray.50"
>
{description ? (
<Box className="markdown-preview">
<ReactMarkdown>{description}</ReactMarkdown>
</Box>
) : (
<Text color="gray.400" fontStyle="italic">
Предпросмотр появится здесь...
</Text>
)}
</Box>
</Tabs.Content>
</Tabs.Root>
<Field.HelperText>Используйте Markdown для форматирования текста</Field.HelperText>
</Field.Root>
{/* Hidden Instructions */}
<Field.Root>
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">
<HStack mb={2}>
<Text fontWeight="bold" color="purple.800">
🔒 Скрытые инструкции для LLM
</Text>
</HStack>
<Text fontSize="sm" color="purple.700" mb={3}>
Эти инструкции будут переданы LLM при проверке решений студентов. Студенты их не
увидят.
</Text>
<Textarea
value={hiddenInstructions}
onChange={(e) => setHiddenInstructions(e.target.value)}
placeholder="Например: Проверь, что сложность алгоритма O(n log n). Код должен обрабатывать edge cases..."
rows={6}
disabled={isLoading}
/>
<Field.HelperText>
Опционально. Используйте для тонкой настройки проверки LLM.
</Field.HelperText>
</Box>
</Field.Root>
{/* Meta info for edit mode */}
{isEdit && task && (
<Box p={4} bg="gray.50" borderRadius="md">
<Text fontSize="sm" color="gray.600">
<strong>Создано:</strong>{' '}
{new Date(task.createdAt).toLocaleString('ru-RU')}
</Text>
{task.creator && (
<Text fontSize="sm" color="gray.600">
<strong>Автор:</strong> {task.creator.preferred_username}
</Text>
)}
{task.updatedAt !== task.createdAt && (
<Text fontSize="sm" color="gray.600">
<strong>Обновлено:</strong>{' '}
{new Date(task.updatedAt).toLocaleString('ru-RU')}
</Text>
)}
</Box>
)}
{/* Actions */}
<HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}>
Отмена
</Button>
<Button type="submit" colorPalette="teal" loading={isLoading}>
{isEdit ? 'Сохранить изменения' : 'Создать задание'}
</Button>
</HStack>
</VStack>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,180 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Heading,
Button,
Table,
Flex,
Input,
HStack,
Text,
IconButton,
Badge,
createListCollection,
} from '@chakra-ui/react'
import { useGetTasksQuery, useDeleteTaskMutation } from '../../__data__/api/api'
import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import type { ChallengeTask } from '../../types/challenge'
import { toaster } from '../../components/ui/toaster'
export const TasksListPage: React.FC = () => {
const navigate = useNavigate()
const { data: tasks, isLoading, error, refetch } = useGetTasksQuery()
const [deleteTask, { isLoading: isDeleting }] = useDeleteTaskMutation()
const [searchQuery, setSearchQuery] = useState('')
const [taskToDelete, setTaskToDelete] = useState<ChallengeTask | null>(null)
const handleDeleteTask = async () => {
if (!taskToDelete) return
try {
await deleteTask(taskToDelete.id).unwrap()
toaster.create({
title: 'Успешно',
description: 'Задание удалено',
type: 'success',
})
setTaskToDelete(null)
} catch (err) {
toaster.create({
title: 'Ошибка',
description: 'Не удалось удалить задание',
type: 'error',
})
}
}
if (isLoading) {
return <LoadingSpinner message="Загрузка заданий..." />
}
if (error || !tasks) {
return <ErrorAlert message="Не удалось загрузить список заданий" onRetry={refetch} />
}
const filteredTasks = tasks.filter((task) =>
task.title.toLowerCase().includes(searchQuery.toLowerCase())
)
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<Box>
<Flex justify="space-between" align="center" mb={6}>
<Heading>Задания</Heading>
<Button colorPalette="teal" onClick={() => navigate(URLs.taskNew)}>
+ Создать задание
</Button>
</Flex>
{tasks.length > 0 && (
<Box mb={4}>
<Input
placeholder="Поиск по названию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
/>
</Box>
)}
{filteredTasks.length === 0 && tasks.length === 0 ? (
<EmptyState
title="Нет заданий"
description="Создайте первое задание для начала работы"
actionLabel="Создать задание"
onAction={() => navigate(URLs.taskNew)}
/>
) : filteredTasks.length === 0 ? (
<EmptyState
title="Ничего не найдено"
description={`По запросу "${searchQuery}" ничего не найдено`}
/>
) : (
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm">
<Table.Header>
<Table.Row>
<Table.ColumnHeader>Название</Table.ColumnHeader>
<Table.ColumnHeader>Создатель</Table.ColumnHeader>
<Table.ColumnHeader>Дата создания</Table.ColumnHeader>
<Table.ColumnHeader>Скрытые инструкции</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{filteredTasks.map((task) => (
<Table.Row key={task.id}>
<Table.Cell fontWeight="medium">{task.title}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{task.creator?.preferred_username || 'N/A'}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{formatDate(task.createdAt)}
</Text>
</Table.Cell>
<Table.Cell>
{task.hiddenInstructions ? (
<Badge colorPalette="purple" variant="subtle">
🔒 Есть
</Badge>
) : (
<Text fontSize="sm" color="gray.400">
</Text>
)}
</Table.Cell>
<Table.Cell textAlign="right">
<HStack gap={2} justify="flex-end">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(URLs.taskEdit(task.id))}
>
Редактировать
</Button>
<Button
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => setTaskToDelete(task)}
>
Удалить
</Button>
</HStack>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)}
<ConfirmDialog
isOpen={!!taskToDelete}
onClose={() => setTaskToDelete(null)}
onConfirm={handleDeleteTask}
title="Удалить задание"
message={`Вы уверены, что хотите удалить задание "${taskToDelete?.title}"? Это действие нельзя отменить.`}
confirmLabel="Удалить"
isLoading={isDeleting}
/>
</Box>
)
}

View File

@@ -0,0 +1,281 @@
import React, { useState } from 'react'
import {
Box,
Heading,
Table,
Input,
Text,
Button,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Grid,
VStack,
HStack,
Badge,
Progress,
} from '@chakra-ui/react'
import { useGetUsersQuery, useGetUserStatsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState'
import type { ChallengeUser } from '../../types/challenge'
export const UsersPage: React.FC = () => {
const { data: users, isLoading, error, refetch } = useGetUsersQuery()
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
if (isLoading) {
return <LoadingSpinner message="Загрузка пользователей..." />
}
if (error || !users) {
return <ErrorAlert message="Не удалось загрузить список пользователей" onRetry={refetch} />
}
const filteredUsers = users.filter((user) =>
user.nickname.toLowerCase().includes(searchQuery.toLowerCase())
)
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<Box>
<Heading mb={6}>Пользователи</Heading>
{users.length > 0 && (
<Box mb={4}>
<Input
placeholder="Поиск по nickname..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
/>
</Box>
)}
{filteredUsers.length === 0 && users.length === 0 ? (
<EmptyState title="Нет пользователей" description="Пользователи появятся после регистрации" />
) : filteredUsers.length === 0 ? (
<EmptyState
title="Ничего не найдено"
description={`По запросу "${searchQuery}" ничего не найдено`}
/>
) : (
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm">
<Table.Header>
<Table.Row>
<Table.ColumnHeader>Nickname</Table.ColumnHeader>
<Table.ColumnHeader>ID</Table.ColumnHeader>
<Table.ColumnHeader>Дата регистрации</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{filteredUsers.map((user) => (
<Table.Row key={user.id}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="xs" fontFamily="monospace" color="gray.600">
{user.id}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{formatDate(user.createdAt)}
</Text>
</Table.Cell>
<Table.Cell textAlign="right">
<Button
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => setSelectedUserId(user.id)}
>
Статистика
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)}
{/* User Stats Modal */}
<UserStatsModal
userId={selectedUserId}
isOpen={!!selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
</Box>
)
}
interface UserStatsModalProps {
userId: string | null
isOpen: boolean
onClose: () => void
}
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
skip: !userId,
})
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>Статистика пользователя</DialogTitle>
</DialogHeader>
<DialogBody>
{isLoading ? (
<LoadingSpinner message="Загрузка статистики..." />
) : !stats ? (
<Text color="gray.600">Нет данных</Text>
) : (
<VStack gap={6} align="stretch">
{/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box>
<Text fontSize="sm" color="gray.600">
Выполнено
</Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
Всего попыток
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
В процессе
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
Требует доработки
</Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks}
</Text>
</Box>
</Grid>
{/* Chains Progress */}
{stats.chainStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
Прогресс по цепочкам
</Text>
<VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks} / {chain.totalTasks}
</Text>
</HStack>
<Progress value={chain.progress} colorPalette="teal" size="sm" />
</Box>
))}
</VStack>
</Box>
)}
{/* Task Stats */}
{stats.taskStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
Задания
</Text>
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
{stats.taskStats.map((taskStat) => (
<Box
key={taskStat.taskId}
p={3}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{taskStat.taskTitle}
</Text>
<Badge
colorPalette={
taskStat.status === 'completed'
? 'green'
: taskStat.status === 'needs_revision'
? 'red'
: 'gray'
}
>
{taskStat.status === 'completed'
? 'Завершено'
: taskStat.status === 'needs_revision'
? 'Доработка'
: taskStat.status === 'in_progress'
? 'В процессе'
: 'Не начато'}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
Попыток: {taskStat.totalAttempts}
</Text>
</Box>
))}
</VStack>
</Box>
)}
{/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}>
Среднее время проверки
</Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700">
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек
</Text>
</Box>
</VStack>
)}
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
Закрыть
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}

79
src/theme.tsx Normal file
View File

@@ -0,0 +1,79 @@
import React from 'react'
import { ChakraProvider as ChacraProv, createSystem, defaultConfig } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
const ChacraProvider: React.ElementType = ChacraProv
const system = createSystem(defaultConfig, {
globalCss: {
body: {
colorPalette: 'teal',
},
'.markdown-preview': {
'& h1': {
fontSize: '2xl',
fontWeight: 'bold',
marginTop: '4',
marginBottom: '2',
},
'& h2': {
fontSize: 'xl',
fontWeight: 'bold',
marginTop: '3',
marginBottom: '2',
},
'& h3': {
fontSize: 'lg',
fontWeight: 'semibold',
marginTop: '2',
marginBottom: '1',
},
'& p': {
marginBottom: '2',
},
'& ul, & ol': {
marginLeft: '4',
marginBottom: '2',
},
'& code': {
backgroundColor: 'gray.100',
padding: '0.125rem 0.25rem',
borderRadius: 'sm',
fontSize: 'sm',
fontFamily: 'monospace',
},
'& pre': {
backgroundColor: 'gray.100',
padding: '3',
borderRadius: 'md',
marginBottom: '2',
overflowX: 'auto',
},
'& pre code': {
backgroundColor: 'transparent',
padding: '0',
},
},
},
theme: {
tokens: {
fonts: {
body: { value: 'var(--font-outfit)' },
},
},
semanticTokens: {
radii: {
l1: { value: '0.5rem' },
l2: { value: '0.75rem' },
l3: { value: '1rem' },
},
},
},
})
export const Provider = (props: PropsWithChildren) => (
<ChacraProvider value={system}>
{props.children}
</ChacraProvider>
)

141
src/types/challenge.ts Normal file
View File

@@ -0,0 +1,141 @@
// Challenge Service Types
export interface ChallengeUser {
_id: string
id: string
nickname: string
createdAt: string
}
export interface ChallengeTask {
_id: string
id: string
title: string
description: string // Markdown
hiddenInstructions?: string // Только для преподавателей
creator?: {
sub: string
preferred_username: string
email?: string
} // Только для преподавателей
createdAt: string
updatedAt: string
}
export interface ChallengeChain {
_id: string
id: string
name: string
tasks: ChallengeTask[] // Populated
createdAt: string
updatedAt: string
}
export type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision'
export interface ChallengeSubmission {
_id: string
id: string
user: ChallengeUser | string
task: ChallengeTask | string
result: string
status: SubmissionStatus
queueId?: string
feedback?: string
submittedAt: string
checkedAt?: string
attemptNumber: number
}
export type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found'
export interface QueueStatus {
status: QueueStatusType
submission?: ChallengeSubmission & { task: ChallengeTask }
error?: string
position?: number
}
export interface TaskStats {
taskId: string
taskTitle: string
attempts: Array<{
attemptNumber: number
status: SubmissionStatus
submittedAt: string
checkedAt?: string
feedback?: string
}>
totalAttempts: number
status: 'not_attempted' | 'pending' | 'in_progress' | 'completed' | 'needs_revision'
lastAttemptAt: string | null
}
export interface ChainStats {
chainId: string
chainName: string
totalTasks: number
completedTasks: number
progress: number // 0-100
}
export interface UserStats {
totalTasksAttempted: number
completedTasks: number
inProgressTasks: number
needsRevisionTasks: number
totalSubmissions: number
averageCheckTimeMs: number
taskStats: TaskStats[]
chainStats: ChainStats[]
}
export interface SystemStats {
users: number
tasks: number
chains: number
submissions: {
total: number
accepted: number
rejected: number
pending: number
inProgress: number
}
averageCheckTimeMs: number
queue: {
queueLength: number
waiting: number
inProgress: number
maxConcurrency: number
currentlyProcessing: number
}
}
// API Request/Response types
export interface APIResponse<T> {
error: any
data: T
}
export interface CreateTaskRequest {
title: string
description: string
hiddenInstructions?: string
}
export interface UpdateTaskRequest {
title?: string
description?: string
hiddenInstructions?: string
}
export interface CreateChainRequest {
name: string
tasks: string[] // Array of task IDs
}
export interface UpdateChainRequest {
name?: string
tasks?: string[]
}

View File

@@ -0,0 +1,59 @@
const STORAGE_KEY = 'auth.loop.attempts'
const DEFAULT_WINDOW_MS = 2_000
const DEFAULT_THRESHOLD = 2
const readAttempts = (): number[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter((n) => typeof n === 'number')
return []
} catch {
return []
}
}
const writeAttempts = (attempts: number[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(attempts))
} catch {
// ignore
}
}
export const recordAuthAttempt = () => {
const now = Date.now()
const attempts = readAttempts()
const updated = [...attempts, now].slice(-10)
writeAttempts(updated)
}
export const clearAuthAttempts = () => {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// ignore
}
}
export const getRecentAttempts = (windowMs: number = DEFAULT_WINDOW_MS): number[] => {
const now = Date.now()
return readAttempts().filter((ts) => now - ts <= windowMs)
}
export const isAuthLoopBlocked = (
windowMs: number = DEFAULT_WINDOW_MS,
threshold: number = DEFAULT_THRESHOLD,
): boolean => {
return getRecentAttempts(windowMs).length >= threshold
}
export const AUTH_LOOP_GUARD = {
recordAuthAttempt,
clearAuthAttempts,
getRecentAttempts,
isAuthLoopBlocked,
}