init + api use
This commit is contained in:
168
src/__data__/api/api.ts
Normal file
168
src/__data__/api/api.ts
Normal 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
8
src/__data__/kc.ts
Normal 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,
|
||||
});
|
||||
|
||||
10
src/__data__/slices/user.ts
Normal file
10
src/__data__/slices/user.ts
Normal 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
24
src/__data__/store.ts
Normal 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
36
src/__data__/urls.ts
Normal 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
26
src/app.tsx
Normal 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
|
||||
61
src/components/ConfirmDialog.tsx
Normal file
61
src/components/ConfirmDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
46
src/components/EmptyState.tsx
Normal file
46
src/components/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
33
src/components/ErrorAlert.tsx
Normal file
33
src/components/ErrorAlert.tsx
Normal 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
103
src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
18
src/components/LoadingSpinner.tsx
Normal file
18
src/components/LoadingSpinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
37
src/components/StatCard.tsx
Normal file
37
src/components/StatCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
46
src/components/StatusBadge.tsx
Normal file
46
src/components/StatusBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
12
src/components/ui/toaster.tsx
Normal file
12
src/components/ui/toaster.tsx
Normal 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
107
src/dashboard.tsx
Normal 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
68
src/index.tsx
Normal 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()
|
||||
}
|
||||
320
src/pages/chains/ChainFormPage.tsx
Normal file
320
src/pages/chains/ChainFormPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
166
src/pages/chains/ChainsListPage.tsx
Normal file
166
src/pages/chains/ChainsListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
162
src/pages/dashboard/DashboardPage.tsx
Normal file
162
src/pages/dashboard/DashboardPage.tsx
Normal 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
4
src/pages/index.ts
Normal 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
2
src/pages/main/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MainPage as default } from './main'
|
||||
|
||||
11
src/pages/main/main.tsx
Normal file
11
src/pages/main/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
export const MainPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Главная страница проекта challenge-admin-pl</h1>
|
||||
<p>Это базовая страница с React Router</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
340
src/pages/submissions/SubmissionsPage.tsx
Normal file
340
src/pages/submissions/SubmissionsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
244
src/pages/tasks/TaskFormPage.tsx
Normal file
244
src/pages/tasks/TaskFormPage.tsx
Normal 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="# Заголовок задания Описание задания в формате 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>
|
||||
)
|
||||
}
|
||||
|
||||
180
src/pages/tasks/TasksListPage.tsx
Normal file
180
src/pages/tasks/TasksListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
281
src/pages/users/UsersPage.tsx
Normal file
281
src/pages/users/UsersPage.tsx
Normal 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
79
src/theme.tsx
Normal 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
141
src/types/challenge.ts
Normal 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[]
|
||||
}
|
||||
|
||||
59
src/utils/authLoopGuard.ts
Normal file
59
src/utils/authLoopGuard.ts
Normal 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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user