Remove Keycloak integration and related authentication logic. Update dashboard to exclude admin page and simplify user management. Introduce new components for chain selection, header, and login form. Refactor main page to handle user authentication and task selection more effectively. Update API routes for challenge-related endpoints.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
This commit is contained in:
parent
9b511a8e1e
commit
b2eaaebd7f
@ -33,7 +33,6 @@
|
|||||||
"eslint-plugin-react": "^7.36.1",
|
"eslint-plugin-react": "^7.36.1",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"keycloak-js": "^26.2.1",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import type {
|
|||||||
SystemStats,
|
SystemStats,
|
||||||
UserStats,
|
UserStats,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { keycloak } from '../kc'
|
|
||||||
|
|
||||||
const normalizeBaseUrl = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url)
|
const normalizeBaseUrl = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url)
|
||||||
const backendBaseUrl = normalizeBaseUrl(getConfigValue('challenge.api'))
|
const backendBaseUrl = normalizeBaseUrl(getConfigValue('challenge.api'))
|
||||||
@ -22,23 +21,8 @@ export const api = createApi({
|
|||||||
reducerPath: 'challengeApi',
|
reducerPath: 'challengeApi',
|
||||||
baseQuery: fetchBaseQuery({
|
baseQuery: fetchBaseQuery({
|
||||||
baseUrl: challengeBaseUrl,
|
baseUrl: challengeBaseUrl,
|
||||||
fetchFn: async (
|
|
||||||
input: RequestInfo | URL,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => {
|
|
||||||
const response = await fetch(input, init)
|
|
||||||
|
|
||||||
if (response.status === 403) keycloak.login()
|
|
||||||
|
|
||||||
return response
|
|
||||||
},
|
|
||||||
prepareHeaders: (headers) => {
|
prepareHeaders: (headers) => {
|
||||||
headers.set('Content-Type', 'application/json;charset=utf-8')
|
headers.set('Content-Type', 'application/json;charset=utf-8')
|
||||||
|
|
||||||
if (keycloak?.token) {
|
|
||||||
headers.set('Authorization', `Bearer ${keycloak.token}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
return headers
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
import Keycloak from 'keycloak-js'
|
|
||||||
|
|
||||||
export const keycloak = new Keycloak({
|
|
||||||
url: KC_URL,
|
|
||||||
realm: KC_REALM,
|
|
||||||
clientId: KC_CLIENT_ID,
|
|
||||||
})
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit'
|
|
||||||
|
|
||||||
import { UserData } from '../types'
|
|
||||||
|
|
||||||
export const userSlice = createSlice({
|
|
||||||
name: 'user',
|
|
||||||
initialState: null as UserData,
|
|
||||||
reducers: {
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -295,105 +295,3 @@ export interface ABTestMetrics {
|
|||||||
satisfactionScore?: number
|
satisfactionScore?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Данные токена аутентификации
|
|
||||||
*/
|
|
||||||
interface TokenData {
|
|
||||||
/** Время истечения токена */
|
|
||||||
exp: number;
|
|
||||||
/** Время выдачи токена */
|
|
||||||
iat: number;
|
|
||||||
/** Время аутентификации */
|
|
||||||
auth_time: number;
|
|
||||||
/** Уникальный идентификатор токена */
|
|
||||||
jti: string;
|
|
||||||
/** Издатель токена */
|
|
||||||
iss: string;
|
|
||||||
/** Аудитория токена */
|
|
||||||
aud: string[];
|
|
||||||
/** Идентификатор пользователя */
|
|
||||||
sub: string;
|
|
||||||
/** Тип токена */
|
|
||||||
typ: string;
|
|
||||||
/** Идентификатор клиента */
|
|
||||||
azp: string;
|
|
||||||
/** Одноразовое значение */
|
|
||||||
nonce: string;
|
|
||||||
/** Состояние сессии */
|
|
||||||
session_state: string;
|
|
||||||
/** Уровень аутентификации */
|
|
||||||
acr: string;
|
|
||||||
/** Разрешенные источники */
|
|
||||||
"allowed-origins": string[];
|
|
||||||
/** Доступ к области */
|
|
||||||
realm_access: Realmaccess;
|
|
||||||
/** Доступ к ресурсам */
|
|
||||||
resource_access: Resourceaccess;
|
|
||||||
/** Область действия токена */
|
|
||||||
scope: string;
|
|
||||||
/** Идентификатор сессии */
|
|
||||||
sid: string;
|
|
||||||
/** Подтвержден ли email */
|
|
||||||
email_verified: boolean;
|
|
||||||
/** Полное имя пользователя */
|
|
||||||
name: string;
|
|
||||||
/** Предпочитаемое имя пользователя */
|
|
||||||
preferred_username: string;
|
|
||||||
/** Имя пользователя */
|
|
||||||
given_name: string;
|
|
||||||
/** Фамилия пользователя */
|
|
||||||
family_name: string;
|
|
||||||
/** Email пользователя */
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Доступ к ресурсам
|
|
||||||
*/
|
|
||||||
interface Resourceaccess {
|
|
||||||
/** Доступ к журналу */
|
|
||||||
journal: Realmaccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Доступ к области
|
|
||||||
*/
|
|
||||||
interface Realmaccess {
|
|
||||||
/** Роли пользователя */
|
|
||||||
roles: (string | "teacher")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Расширенные данные пользователя
|
|
||||||
*/
|
|
||||||
export interface UserData extends TokenData {
|
|
||||||
/** Идентификатор пользователя */
|
|
||||||
sub: string;
|
|
||||||
/** URL аватара пользователя */
|
|
||||||
gravatar: string;
|
|
||||||
/** Подтвержден ли email */
|
|
||||||
email_verified: boolean;
|
|
||||||
/** Дополнительные атрибуты пользователя */
|
|
||||||
attributes: Record<string, string[]>;
|
|
||||||
/** Полное имя пользователя */
|
|
||||||
name: string;
|
|
||||||
/** Предпочитаемое имя пользователя */
|
|
||||||
preferred_username: string;
|
|
||||||
/** Имя пользователя */
|
|
||||||
given_name: string;
|
|
||||||
/** Фамилия пользователя */
|
|
||||||
family_name: string;
|
|
||||||
/** Email пользователя */
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Базовый ответ API
|
|
||||||
*/
|
|
||||||
export type BaseResponse<Data> = {
|
|
||||||
/** Успешность операции */
|
|
||||||
success: boolean;
|
|
||||||
/** Данные ответа */
|
|
||||||
body: Data;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
117
src/components/ChainSelector.tsx
Normal file
117
src/components/ChainSelector.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Heading,
|
||||||
|
SimpleGrid,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import type { ChallengeChain } from '../__data__/types'
|
||||||
|
import { useChallenge } from '../context/ChallengeContext'
|
||||||
|
|
||||||
|
interface ChainSelectorProps {
|
||||||
|
onSelectChain: (chain: ChallengeChain) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChainSelector = ({ onSelectChain }: ChainSelectorProps) => {
|
||||||
|
const { chains, personalDashboard } = useChallenge()
|
||||||
|
|
||||||
|
if (chains.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderColor="gray.200"
|
||||||
|
p={8}
|
||||||
|
maxW="600px"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
Нет доступных цепочек
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600">
|
||||||
|
Свяжитесь с преподавателем для получения доступа к заданиям.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||||||
|
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
||||||
|
<Box>
|
||||||
|
<Heading size="lg" mb={2}>
|
||||||
|
Выберите цепочку заданий
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600">
|
||||||
|
Выберите цепочку заданий для начала работы. Вы можете проходить несколько цепочек параллельно.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||||
|
{chains.map((chain) => {
|
||||||
|
const chainStats = personalDashboard?.activeChains.find(
|
||||||
|
(stat) => stat.chainId === chain.id
|
||||||
|
)
|
||||||
|
const completedTasks = chainStats?.completedTasks ?? 0
|
||||||
|
const progress = chainStats?.progress ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={chain.id}
|
||||||
|
bg="white"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderColor="gray.200"
|
||||||
|
p={6}
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{ shadow: 'md', borderColor: 'teal.300' }}
|
||||||
|
>
|
||||||
|
<VStack align="stretch" spacing={4} height="full">
|
||||||
|
<Box flex="1">
|
||||||
|
<Heading size="md" mb={3}>
|
||||||
|
{chain.name}
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600" fontSize="sm" mb={3}>
|
||||||
|
Заданий в цепочке: {chain.tasks.length}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{completedTasks > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.500" mb={1}>
|
||||||
|
Прогресс: {completedTasks} / {chain.tasks.length}
|
||||||
|
</Text>
|
||||||
|
<Box bg="gray.100" borderRadius="full" height="8px" overflow="hidden">
|
||||||
|
<Box
|
||||||
|
bg="teal.500"
|
||||||
|
height="full"
|
||||||
|
width={`${progress}%`}
|
||||||
|
transition="width 0.3s"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => onSelectChain(chain)}
|
||||||
|
colorScheme="teal"
|
||||||
|
width="full"
|
||||||
|
>
|
||||||
|
{completedTasks > 0 ? 'Продолжить' : 'Начать'}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
29
src/components/Header.tsx
Normal file
29
src/components/Header.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import { useChallenge } from '../context/ChallengeContext'
|
||||||
|
|
||||||
|
export const Header = () => {
|
||||||
|
const { nickname, logout } = useChallenge()
|
||||||
|
|
||||||
|
if (!nickname) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="white" borderBottomWidth="1px" borderColor="gray.200" py={4} px={{ base: 4, md: 8 }}>
|
||||||
|
<Flex maxW="1200px" mx="auto" justify="space-between" align="center">
|
||||||
|
<Box>
|
||||||
|
<Heading size="md" color="teal.600">
|
||||||
|
Challenge Platform
|
||||||
|
</Heading>
|
||||||
|
<Text fontSize="sm" color="gray.600" mt={1}>
|
||||||
|
{nickname}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button onClick={logout} variant="ghost" size="sm">
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
99
src/components/LoginForm.tsx
Normal file
99
src/components/LoginForm.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Heading,
|
||||||
|
Input,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
|
||||||
|
import { useChallenge } from '../context/ChallengeContext'
|
||||||
|
|
||||||
|
export const LoginForm = () => {
|
||||||
|
const [fullName, setFullName] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const { login, isAuthLoading } = useChallenge()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const trimmedName = fullName.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError('Пожалуйста, введите ваше ФИО')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName.length < 3) {
|
||||||
|
setError('ФИО должно содержать минимум 3 символа')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
await login(trimmedName)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Произошла ошибка при входе. Попробуйте снова.')
|
||||||
|
console.error('Login error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderColor="gray.200"
|
||||||
|
p={8}
|
||||||
|
maxW="480px"
|
||||||
|
w="full"
|
||||||
|
>
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
<Box textAlign="center">
|
||||||
|
<Heading size="lg" color="teal.600" mb={2}>
|
||||||
|
Challenge Platform
|
||||||
|
</Heading>
|
||||||
|
<Text color="gray.600">
|
||||||
|
Добро пожаловать! Введите ваше ФИО для начала работы
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="medium" mb={2}>
|
||||||
|
Ваше ФИО
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
placeholder="Иванов Иван Иванович"
|
||||||
|
size="lg"
|
||||||
|
autoFocus
|
||||||
|
isInvalid={!!error}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text color="red.500" fontSize="sm" mt={2}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorScheme="teal"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isAuthLoading}
|
||||||
|
isDisabled={!fullName.trim()}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Grid,
|
|
||||||
GridItem,
|
|
||||||
Heading,
|
|
||||||
NumberInput,
|
|
||||||
NumberInputInput,
|
|
||||||
Stat,
|
|
||||||
StatHelpText,
|
|
||||||
StatLabel,
|
|
||||||
StatValueText,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
|
|
||||||
import type { ABTestMetrics } from '../../__data__/types'
|
|
||||||
import { compareVariants } from '../../utils/analytics'
|
|
||||||
|
|
||||||
interface VariantFormState {
|
|
||||||
submissionRate: number
|
|
||||||
completionRate: number
|
|
||||||
retryRate: number
|
|
||||||
timeToFirstSubmission: number
|
|
||||||
sessionDuration: number
|
|
||||||
satisfactionScore?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const createVariantState = (): VariantFormState => ({
|
|
||||||
submissionRate: 0,
|
|
||||||
completionRate: 0,
|
|
||||||
retryRate: 0,
|
|
||||||
timeToFirstSubmission: 0,
|
|
||||||
sessionDuration: 0,
|
|
||||||
satisfactionScore: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const buildMetrics = (variant: 'A' | 'B', state: VariantFormState): ABTestMetrics => ({
|
|
||||||
variant,
|
|
||||||
submissionRate: state.submissionRate,
|
|
||||||
completionRate: state.completionRate,
|
|
||||||
retryRate: state.retryRate,
|
|
||||||
timeToFirstSubmission: state.timeToFirstSubmission,
|
|
||||||
sessionDuration: state.sessionDuration,
|
|
||||||
satisfactionScore: state.satisfactionScore,
|
|
||||||
})
|
|
||||||
|
|
||||||
const MetricInput = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
suffix,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: number
|
|
||||||
onChange: (value: number) => void
|
|
||||||
suffix?: string
|
|
||||||
}) => (
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<NumberInput value={value} min={0} onChange={(_, val) => onChange(Number.isNaN(val) ? 0 : val)}>
|
|
||||||
<NumberInputInput />
|
|
||||||
</NumberInput>
|
|
||||||
{suffix && (
|
|
||||||
<StatHelpText fontSize="xs" color="gray.500">
|
|
||||||
{suffix}
|
|
||||||
</StatHelpText>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const ABTestPanel = () => {
|
|
||||||
const [variantA, setVariantA] = useState<VariantFormState>(createVariantState)
|
|
||||||
const [variantB, setVariantB] = useState<VariantFormState>(createVariantState)
|
|
||||||
const [comparison, setComparison] = useState<ReturnType<typeof compareVariants> | null>(null)
|
|
||||||
|
|
||||||
const handleCompare = () => {
|
|
||||||
const metricsA = buildMetrics('A', variantA)
|
|
||||||
const metricsB = buildMetrics('B', variantB)
|
|
||||||
setComparison(compareVariants(metricsA, metricsB))
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasData = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(variantA).some((value) => value !== 0) ||
|
|
||||||
Object.values(variantB).some((value) => value !== 0),
|
|
||||||
[variantA, variantB],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
|
||||||
<Heading size="sm" mb={4}>
|
|
||||||
A/B тест: сравнение вариантов
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4} mb={4}>
|
|
||||||
<GridItem>
|
|
||||||
<Heading size="xs" mb={2}>
|
|
||||||
Вариант A
|
|
||||||
</Heading>
|
|
||||||
<VStack spacing={3} align="stretch">
|
|
||||||
<MetricInput
|
|
||||||
label="Submission Rate (%)"
|
|
||||||
value={variantA.submissionRate}
|
|
||||||
onChange={(value) => setVariantA((prev) => ({ ...prev, submissionRate: value }))}
|
|
||||||
suffix="Процент пользователей, отправивших хотя бы одно решение"
|
|
||||||
/>
|
|
||||||
<MetricInput
|
|
||||||
label="Completion Rate (%)"
|
|
||||||
value={variantA.completionRate}
|
|
||||||
onChange={(value) => setVariantA((prev) => ({ ...prev, completionRate: value }))}
|
|
||||||
/>
|
|
||||||
<MetricInput
|
|
||||||
label="Retry Rate (%)"
|
|
||||||
value={variantA.retryRate}
|
|
||||||
onChange={(value) => setVariantA((prev) => ({ ...prev, retryRate: value }))}
|
|
||||||
/>
|
|
||||||
<MetricInput
|
|
||||||
label="Time to First Submission (мин)"
|
|
||||||
value={variantA.timeToFirstSubmission}
|
|
||||||
onChange={(value) => setVariantA((prev) => ({ ...prev, timeToFirstSubmission: value }))}
|
|
||||||
/>
|
|
||||||
<MetricInput
|
|
||||||
label="Session Duration (мин)"
|
|
||||||
value={variantA.sessionDuration}
|
|
||||||
onChange={(value) => setVariantA((prev) => ({ ...prev, sessionDuration: value }))}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</GridItem>
|
|
||||||
|
|
||||||
<GridItem>
|
|
||||||
<Heading size="xs" mb={2}>
|
|
||||||
Вариант B
|
|
||||||
</Heading>
|
|
||||||
<VStack spacing={3} align="stretch">
|
|
||||||
<MetricInput
|
|
||||||
label="Submission Rate (%)"
|
|
||||||
value={variantB.submissionRate}
|
|
||||||
onChange={(value) => setVariantB((prev) => ({ ...prev, submissionRate: value }))}
|
|
||||||
/>
|
|
||||||
<MetricInput
|
|
||||||
label="Completion Rate (%)"
|
|
||||||
value={variantB.completionRate}
|
|
||||||
onChange={(value) => setVariantB((prev) => ({ ...prev, completionRate: value }))}
|
|
||||||
/>
|
|
||||||
<MetricInput
|
|
||||||
label="Retry Rate (%)"
|
|
||||||
value={variantB.retryRate}
|
|
||||||
onChange={(value) => setVariantB((prev) => ({ ...prev, retryRate: value }))}
|
|
||||||
/>
|
|
||||||
<MetricInput
|
|
||||||
label="Time to First Submission (мин)"
|
|
||||||
value={variantB.timeToFirstSubmission}
|
|
||||||
onChange={(value) => setVariantB((prev) => ({ ...prev, timeToFirstSubmission: value }))}
|
|
||||||
/>
|
|
||||||
<MetricInput
|
|
||||||
label="Session Duration (мин)"
|
|
||||||
value={variantB.sessionDuration}
|
|
||||||
onChange={(value) => setVariantB((prev) => ({ ...prev, sessionDuration: value }))}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</GridItem>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Button onClick={handleCompare} colorScheme="teal" isDisabled={!hasData}>
|
|
||||||
Сравнить варианты
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{comparison && (
|
|
||||||
<Box mt={4} borderWidth="1px" borderRadius="md" borderColor="teal.200" bg="teal.50" p={4}>
|
|
||||||
<Heading size="xs" mb={2}>
|
|
||||||
Результат сравнения
|
|
||||||
</Heading>
|
|
||||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
|
||||||
<Stat>
|
|
||||||
<StatLabel>Δ Submission Rate</StatLabel>
|
|
||||||
<StatValueText>{comparison.submissionRateDiff.toFixed(1)}%</StatValueText>
|
|
||||||
<StatHelpText>Положительное значение — рост у варианта B</StatHelpText>
|
|
||||||
</Stat>
|
|
||||||
<Stat>
|
|
||||||
<StatLabel>Δ Completion Rate</StatLabel>
|
|
||||||
<StatValueText>{comparison.completionRateDiff.toFixed(1)}%</StatValueText>
|
|
||||||
<StatHelpText>Положительное значение — рост у варианта B</StatHelpText>
|
|
||||||
</Stat>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Stat mt={4}>
|
|
||||||
<StatLabel>Победитель</StatLabel>
|
|
||||||
<StatValueText>Вариант {comparison.winner}</StatValueText>
|
|
||||||
<StatHelpText>Основано на сравнении коэффициента завершения</StatHelpText>
|
|
||||||
</Stat>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -2,7 +2,7 @@ import React, { Suspense } from 'react'
|
|||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
import { URLs } from './__data__/urls'
|
import { URLs } from './__data__/urls'
|
||||||
import { AdminPage, MainPage } from './pages'
|
import { MainPage } from './pages'
|
||||||
|
|
||||||
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
||||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||||
@ -19,16 +19,6 @@ export const Dashboard = () => {
|
|||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{URLs.admin.isOn && (
|
|
||||||
<Route
|
|
||||||
path={URLs.admin.url}
|
|
||||||
element={
|
|
||||||
<PageWrapper>
|
|
||||||
<AdminPage />
|
|
||||||
</PageWrapper>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,7 @@ import i18next from 'i18next'
|
|||||||
import { i18nextReactInitConfig } from '@brojs/cli'
|
import { i18nextReactInitConfig } from '@brojs/cli'
|
||||||
|
|
||||||
import App from './app'
|
import App from './app'
|
||||||
import { keycloak } from "./__data__/kc"
|
|
||||||
import { createStore } from "./__data__/store"
|
import { createStore } from "./__data__/store"
|
||||||
import { isAuthLoopBlocked, recordAuthAttempt, clearAuthAttempts } from './utils/authLoopGuard'
|
|
||||||
|
|
||||||
i18next.t = i18next.t.bind(i18next)
|
i18next.t = i18next.t.bind(i18next)
|
||||||
const i18nextPromise = i18nextReactInitConfig(i18next)
|
const i18nextPromise = i18nextReactInitConfig(i18next)
|
||||||
@ -17,41 +15,7 @@ export default (props) => <App {...props} />
|
|||||||
let rootElement: ReactDOM.Root
|
let rootElement: ReactDOM.Root
|
||||||
|
|
||||||
export const mount = async (Component, element = document.getElementById('app')) => {
|
export const mount = async (Component, element = document.getElementById('app')) => {
|
||||||
let user = null
|
const store = createStore()
|
||||||
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'
|
|
||||||
// onLoad: 'check-sso'
|
|
||||||
})
|
|
||||||
|
|
||||||
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 adapter:', error)
|
|
||||||
// keycloak.login()
|
|
||||||
}
|
|
||||||
const store = createStore({ user })
|
|
||||||
await i18nextPromise
|
await i18nextPromise
|
||||||
|
|
||||||
rootElement = ReactDOM.createRoot(element)
|
rootElement = ReactDOM.createRoot(element)
|
||||||
|
|||||||
@ -1,252 +0,0 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Box,
|
|
||||||
HStack,
|
|
||||||
Heading,
|
|
||||||
SimpleGrid,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableColumnHeader,
|
|
||||||
TableContainer,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
Select,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
useGetAllSubmissionsQuery,
|
|
||||||
useGetChainsQuery,
|
|
||||||
useGetSystemStatsQuery,
|
|
||||||
} from '../../__data__/api/api'
|
|
||||||
import type { ChallengeChain } from '../../__data__/types'
|
|
||||||
import { StatCard } from '../../components/personal'
|
|
||||||
import { ABTestPanel } from '../../components/admin/ABTestPanel'
|
|
||||||
import { mapTaskMetrics, detectIssues, msToMinutes } from '../../utils/analytics'
|
|
||||||
import { keycloak } from '../../__data__/kc'
|
|
||||||
|
|
||||||
const formatNumber = (value: number | undefined) => {
|
|
||||||
if (!value && value !== 0) return '—'
|
|
||||||
return Intl.NumberFormat('ru-RU').format(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasTeacherRole = () => {
|
|
||||||
try {
|
|
||||||
return keycloak?.hasResourceRole?.('teacher', 'journal') ?? false
|
|
||||||
} catch (error) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AdminDashboard = () => {
|
|
||||||
const isTeacher = hasTeacherRole()
|
|
||||||
const { data: systemStats, isLoading } = useGetSystemStatsQuery()
|
|
||||||
const { data: chains = [] } = useGetChainsQuery(undefined, { skip: !isTeacher })
|
|
||||||
const { data: submissions = [] } = useGetAllSubmissionsQuery(undefined, { skip: !isTeacher })
|
|
||||||
|
|
||||||
const issues = useMemo(() => (systemStats ? detectIssues(systemStats) : []), [systemStats])
|
|
||||||
const taskMetrics = useMemo(() => mapTaskMetrics(submissions), [submissions])
|
|
||||||
const [difficultyFilter, setDifficultyFilter] = useState<'all' | 'easy' | 'medium' | 'hard'>('all')
|
|
||||||
|
|
||||||
const filteredTaskMetrics = useMemo(() => {
|
|
||||||
if (difficultyFilter === 'all') return taskMetrics
|
|
||||||
return taskMetrics.filter((metric) => metric.difficulty === difficultyFilter)
|
|
||||||
}, [difficultyFilter, taskMetrics])
|
|
||||||
|
|
||||||
if (!isTeacher) {
|
|
||||||
return (
|
|
||||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center">
|
|
||||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={8} maxW="480px" textAlign="center">
|
|
||||||
<Heading size="md" mb={4}>
|
|
||||||
Требуется роль преподавателя
|
|
||||||
</Heading>
|
|
||||||
<Text color="gray.600">
|
|
||||||
У вас нет доступа к панели администратора. Обратитесь к администратору Keycloak для назначения роли
|
|
||||||
<Badge ml={2} colorScheme="purple">
|
|
||||||
teacher
|
|
||||||
</Badge>
|
|
||||||
.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading || !systemStats) {
|
|
||||||
return (
|
|
||||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center">
|
|
||||||
<Text color="gray.500">Загружаем системные метрики...</Text>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
|
||||||
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
|
||||||
<Heading size="lg">Панель преподавателя</Heading>
|
|
||||||
|
|
||||||
<SimpleGrid minChildWidth="200px" spacing={4}>
|
|
||||||
<StatCard title="Пользователей" value={formatNumber(systemStats.users)} icon="👥" />
|
|
||||||
<StatCard title="Заданий" value={formatNumber(systemStats.tasks)} icon="🧩" />
|
|
||||||
<StatCard title="Цепочек" value={formatNumber(systemStats.chains)} icon="🔗" />
|
|
||||||
<StatCard title="Всего проверок" value={formatNumber(systemStats.submissions.total)} icon="✅" />
|
|
||||||
<StatCard
|
|
||||||
title="В ожидании"
|
|
||||||
value={formatNumber(systemStats.queue.waiting)}
|
|
||||||
icon="⏳"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Среднее время проверки"
|
|
||||||
value={`${msToMinutes(systemStats.averageCheckTimeMs)} мин`}
|
|
||||||
icon="⏱️"
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
|
||||||
<Heading size="sm" mb={3}>
|
|
||||||
Статус очереди
|
|
||||||
</Heading>
|
|
||||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="sm" color="gray.500">
|
|
||||||
Всего в очереди
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="lg" fontWeight="semibold">
|
|
||||||
{formatNumber(systemStats.queue.queueLength)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="sm" color="gray.500">
|
|
||||||
В ожидании
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="lg" fontWeight="semibold">
|
|
||||||
{formatNumber(systemStats.queue.waiting)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="sm" color="gray.500">
|
|
||||||
В обработке
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="lg" fontWeight="semibold">
|
|
||||||
{formatNumber(systemStats.queue.currentlyProcessing)} / {formatNumber(systemStats.queue.maxConcurrency)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</SimpleGrid>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
|
||||||
<Heading size="sm" mb={3}>
|
|
||||||
Проблемные области
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
{issues.length === 0 ? (
|
|
||||||
<Text color="green.500">Критических проблем не обнаружено.</Text>
|
|
||||||
) : (
|
|
||||||
<VStack align="stretch" spacing={3}>
|
|
||||||
{issues.map((issue) => (
|
|
||||||
<Box
|
|
||||||
key={`${issue.type}-${issue.message}`}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderRadius="md"
|
|
||||||
borderColor={issue.severity === 'high' ? 'red.200' : 'yellow.200'}
|
|
||||||
bg={issue.severity === 'high' ? 'red.50' : 'yellow.50'}
|
|
||||||
p={3}
|
|
||||||
>
|
|
||||||
<Text fontWeight="medium">{issue.message}</Text>
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
Сущность: {issue.affectedEntity} · Важность: {issue.severity}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
|
||||||
<HStack justify="space-between" mb={4} align="center">
|
|
||||||
<Heading size="sm">Метрики по заданиям</Heading>
|
|
||||||
<Select
|
|
||||||
size="sm"
|
|
||||||
width="200px"
|
|
||||||
value={difficultyFilter}
|
|
||||||
onChange={(event) => setDifficultyFilter(event.target.value as typeof difficultyFilter)}
|
|
||||||
>
|
|
||||||
<option value="all">Все сложности</option>
|
|
||||||
<option value="easy">Лёгкие</option>
|
|
||||||
<option value="medium">Средние</option>
|
|
||||||
<option value="hard">Сложные</option>
|
|
||||||
</Select>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{filteredTaskMetrics.length === 0 ? (
|
|
||||||
<Text color="gray.500">Недостаточно данных о проверках для построения аналитики.</Text>
|
|
||||||
) : (
|
|
||||||
<Box overflowX="auto">
|
|
||||||
<Table size="sm" variant="simple">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableColumnHeader>Задание</TableColumnHeader>
|
|
||||||
<TableColumnHeader textAlign="right">Попыток</TableColumnHeader>
|
|
||||||
<TableColumnHeader textAlign="right">Успешность</TableColumnHeader>
|
|
||||||
<TableColumnHeader textAlign="right">Сред. попыток</TableColumnHeader>
|
|
||||||
<TableColumnHeader textAlign="right">Сред. время (мин)</TableColumnHeader>
|
|
||||||
<TableColumnHeader>Сложность</TableColumnHeader>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredTaskMetrics.map((metric) => (
|
|
||||||
<TableRow key={metric.taskId}>
|
|
||||||
<TableCell>
|
|
||||||
<Text fontWeight="medium">{metric.title}</Text>
|
|
||||||
<Text fontSize="xs" color="gray.500">
|
|
||||||
ID: {metric.taskId}
|
|
||||||
</Text>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell textAlign="right">{formatNumber(metric.attemptsCount)}</TableCell>
|
|
||||||
<TableCell textAlign="right">{formatNumber(Math.round(metric.successRate))}%</TableCell>
|
|
||||||
<TableCell textAlign="right">{formatNumber(Math.round(metric.avgAttempts * 10) / 10)}</TableCell>
|
|
||||||
<TableCell textAlign="right">{formatNumber(Math.round(msToMinutes(metric.avgTimeToComplete * 1000)))}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge colorScheme={metric.difficulty === 'hard' ? 'red' : metric.difficulty === 'medium' ? 'yellow' : 'green'}>
|
|
||||||
{metric.difficulty}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
|
||||||
<Heading size="sm" mb={3}>
|
|
||||||
Цепочки заданий
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
{chains.length === 0 ? (
|
|
||||||
<Text color="gray.500">Цепочки не найдены. Создайте первое задание, чтобы начать.</Text>
|
|
||||||
) : (
|
|
||||||
<VStack align="stretch" spacing={3}>
|
|
||||||
{chains.map((chain: ChallengeChain) => (
|
|
||||||
<Box key={chain.id} borderWidth="1px" borderRadius="md" borderColor="gray.200" p={3}>
|
|
||||||
<Text fontWeight="medium">{chain.name}</Text>
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
Заданий: {chain.tasks.length} · Последнее обновление:{' '}
|
|
||||||
{new Date(chain.updatedAt).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<ABTestPanel />
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { AdminDashboard } from './AdminDashboard'
|
|
||||||
|
|
||||||
export default AdminDashboard
|
|
||||||
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import { lazy } from 'react'
|
import { lazy } from 'react'
|
||||||
|
|
||||||
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
||||||
export const AdminPage = lazy(() => import(/* webpackChunkName: 'admin' */'./admin'))
|
|
||||||
@ -1,21 +1,22 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
AlertIndicator,
|
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Flex,
|
Flex,
|
||||||
Heading,
|
Heading,
|
||||||
SimpleGrid,
|
|
||||||
Text,
|
Text,
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
|
import { Alert } from '@chakra-ui/react/alert'
|
||||||
|
|
||||||
import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
|
import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
|
||||||
import { useChallenge } from '../../context/ChallengeContext'
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
import { MobileDashboard, PersonalDashboard, TaskWorkspace } from '../../components/personal'
|
import { TaskWorkspace } from '../../components/personal'
|
||||||
|
import { Header } from '../../components/Header'
|
||||||
|
import { LoginForm } from '../../components/LoginForm'
|
||||||
|
import { ChainSelector } from '../../components/ChainSelector'
|
||||||
|
|
||||||
export const MainPage = () => {
|
export const MainPage = () => {
|
||||||
const { nickname, personalDashboard, chains, eventEmitter } = useChallenge()
|
const { nickname, eventEmitter } = useChallenge()
|
||||||
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
|
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
|
||||||
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
|
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
|
||||||
const [isOffline, setIsOffline] = useState(() =>
|
const [isOffline, setIsOffline] = useState(() =>
|
||||||
@ -24,18 +25,9 @@ export const MainPage = () => {
|
|||||||
const [notification, setNotification] = useState<{ status: 'success' | 'warning'; title: string; description?: string } | null>(null)
|
const [notification, setNotification] = useState<{ status: 'success' | 'warning'; title: string; description?: string } | null>(null)
|
||||||
const notificationTimeoutRef = useRef<number | null>(null)
|
const notificationTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const isTaskSelected = Boolean(selectedChain && selectedTask)
|
const handleSelectChain = (chain: ChallengeChain) => {
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
|
||||||
if (nickname) {
|
|
||||||
return `Привет, ${nickname}!`
|
|
||||||
}
|
|
||||||
return 'Challenge Platform'
|
|
||||||
}, [nickname])
|
|
||||||
|
|
||||||
const handleSelectTask = (task: ChallengeTask, chain: ChallengeChain) => {
|
|
||||||
setSelectedChain(chain)
|
setSelectedChain(chain)
|
||||||
setSelectedTask(task)
|
setSelectedTask(chain.tasks[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTaskComplete = () => {
|
const handleTaskComplete = () => {
|
||||||
@ -46,21 +38,20 @@ export const MainPage = () => {
|
|||||||
if (nextTask) {
|
if (nextTask) {
|
||||||
setSelectedTask(nextTask)
|
setSelectedTask(nextTask)
|
||||||
} else {
|
} else {
|
||||||
|
// Цепочка завершена, возвращаемся к выбору
|
||||||
setSelectedChain(null)
|
setSelectedChain(null)
|
||||||
setSelectedTask(null)
|
setSelectedTask(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackTask = useMemo(() => {
|
const handleBackToChains = () => {
|
||||||
if (selectedTask) return selectedTask
|
setSelectedChain(null)
|
||||||
if (selectedChain) return selectedChain.tasks[0]
|
setSelectedTask(null)
|
||||||
if (chains.length) return chains[0].tasks[0]
|
}
|
||||||
return null
|
|
||||||
}, [chains, selectedChain, selectedTask])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventEmitter.on('submission_completed', (event) => {
|
const unsubscribe = eventEmitter.on('submission_completed', (event) => {
|
||||||
const submission = (event.data as any)?.submission
|
const submission = (event.data as { submission?: { status: string; attemptNumber: number } })?.submission
|
||||||
const accepted = submission?.status === 'accepted'
|
const accepted = submission?.status === 'accepted'
|
||||||
const title = accepted ? 'Задание принято' : 'Задание требует доработки'
|
const title = accepted ? 'Задание принято' : 'Задание требует доработки'
|
||||||
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
|
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
|
||||||
@ -94,65 +85,63 @@ export const MainPage = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Если пользователь не авторизован, показываем форму входа
|
||||||
|
if (!nickname) {
|
||||||
|
return <LoginForm />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если цепочка не выбрана, показываем селектор цепочек
|
||||||
|
if (!selectedChain) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<ChainSelector onSelectChain={handleSelectChain} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем выбранную цепочку и задания
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||||||
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
<Box maxW="1200px" mx="auto">
|
||||||
{notification && (
|
{notification && (
|
||||||
<Alert status={notification.status} borderRadius="md">
|
<Alert.Root status={notification.status} borderRadius="md" mb={4}>
|
||||||
<AlertIndicator />
|
<Alert.Indicator />
|
||||||
<Box ml={3}>
|
<Box ml={3}>
|
||||||
<Text fontWeight="semibold">{notification.title}</Text>
|
<Text fontWeight="semibold">{notification.title}</Text>
|
||||||
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
|
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
</Alert>
|
</Alert.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isOffline && (
|
{isOffline && (
|
||||||
<Alert status="warning" borderRadius="md">
|
<Alert.Root status="warning" borderRadius="md" mb={4}>
|
||||||
<AlertIndicator />
|
<Alert.Indicator />
|
||||||
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
|
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
|
||||||
</Alert>
|
</Alert.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Flex justify="space-between" align="center" mb={8}>
|
||||||
<Box>
|
<Box>
|
||||||
<Heading size="lg" mb={1}>
|
<Heading size="lg" mb={1}>
|
||||||
{pageTitle}
|
{selectedChain.name}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text color="gray.600">Следите за прогрессом и отправляйте решения в одном месте.</Text>
|
<Text color="gray.600">
|
||||||
</Box>
|
Задание {selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1} из {selectedChain.tasks.length}
|
||||||
|
|
||||||
<MobileDashboard />
|
|
||||||
|
|
||||||
<SimpleGrid columns={{ base: 1, xl: 2 }} spacing={6} alignItems="start">
|
|
||||||
<PersonalDashboard onSelectTask={handleSelectTask} />
|
|
||||||
|
|
||||||
<Box position="sticky" top={8} height="fit-content">
|
|
||||||
<Heading size="md" mb={4}>
|
|
||||||
Рабочее пространство
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
{personalDashboard && isTaskSelected && selectedTask && selectedChain ? (
|
|
||||||
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
|
|
||||||
) : fallbackTask ? (
|
|
||||||
<TaskWorkspace task={fallbackTask} onTaskComplete={handleTaskComplete} />
|
|
||||||
) : (
|
|
||||||
<Flex
|
|
||||||
borderWidth="1px"
|
|
||||||
borderRadius="lg"
|
|
||||||
borderColor="gray.200"
|
|
||||||
bg="white"
|
|
||||||
height="260px"
|
|
||||||
align="center"
|
|
||||||
justify="center"
|
|
||||||
>
|
|
||||||
<Text color="gray.500" textAlign="center" px={6}>
|
|
||||||
Нет доступных заданий. Попросите преподавателя открыть цепочку или попробуйте позже.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button onClick={handleBackToChains} variant="outline">
|
||||||
|
Вернуться к цепочкам
|
||||||
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{selectedTask && (
|
||||||
|
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</SimpleGrid>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@ -22,15 +22,16 @@ router.use((req, res, next) => {
|
|||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/auth', (req, res) => {
|
// Challenge API endpoints
|
||||||
|
router.post('/challenge/auth', (req, res) => {
|
||||||
res.json(readJson('auth.json'))
|
res.json(readJson('auth.json'))
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/chains', (req, res) => {
|
router.get('/challenge/chains', (req, res) => {
|
||||||
res.json(readJson('chains.json'))
|
res.json(readJson('chains.json'))
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/chain/:id', (req, res) => {
|
router.get('/challenge/chain/:id', (req, res) => {
|
||||||
const chains = readJson('chains.json')
|
const chains = readJson('chains.json')
|
||||||
const chain = chains.find((item) => item.id === req.params.id || item._id === req.params.id)
|
const chain = chains.find((item) => item.id === req.params.id || item._id === req.params.id)
|
||||||
if (!chain) {
|
if (!chain) {
|
||||||
@ -39,7 +40,7 @@ router.get('/chain/:id', (req, res) => {
|
|||||||
return res.json(chain)
|
return res.json(chain)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/task/:id', (req, res) => {
|
router.get('/challenge/task/:id', (req, res) => {
|
||||||
const chains = readJson('chains.json')
|
const chains = readJson('chains.json')
|
||||||
const task = chains
|
const task = chains
|
||||||
.flatMap((chain) => chain.tasks || [])
|
.flatMap((chain) => chain.tasks || [])
|
||||||
@ -52,12 +53,12 @@ router.get('/task/:id', (req, res) => {
|
|||||||
return res.json(task)
|
return res.json(task)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/submit', (req, res) => {
|
router.post('/challenge/submit', (req, res) => {
|
||||||
const response = readJson('submit.json')
|
const response = readJson('submit.json')
|
||||||
res.json(response)
|
res.json(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/check-status/:queueId', (req, res) => {
|
router.get('/challenge/check-status/:queueId', (req, res) => {
|
||||||
const statuses = readJson('queue-status.json')
|
const statuses = readJson('queue-status.json')
|
||||||
const status = statuses[req.params.queueId]
|
const status = statuses[req.params.queueId]
|
||||||
|
|
||||||
@ -68,7 +69,7 @@ router.get('/check-status/:queueId', (req, res) => {
|
|||||||
return res.json(status)
|
return res.json(status)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/user/:userId/stats', (req, res) => {
|
router.get('/challenge/user/:userId/stats', (req, res) => {
|
||||||
const statsMap = readJson('user-stats.json')
|
const statsMap = readJson('user-stats.json')
|
||||||
const stats = statsMap[req.params.userId]
|
const stats = statsMap[req.params.userId]
|
||||||
|
|
||||||
@ -79,17 +80,17 @@ router.get('/user/:userId/stats', (req, res) => {
|
|||||||
return res.json(stats)
|
return res.json(stats)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/user/:userId/submissions', (req, res) => {
|
router.get('/challenge/user/:userId/submissions', (req, res) => {
|
||||||
const submissionsMap = readJson('user-submissions.json')
|
const submissionsMap = readJson('user-submissions.json')
|
||||||
const submissions = submissionsMap[req.params.userId] || []
|
const submissions = submissionsMap[req.params.userId] || []
|
||||||
return res.json(submissions)
|
return res.json(submissions)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/stats', (req, res) => {
|
router.get('/challenge/stats', (req, res) => {
|
||||||
res.json(readJson('system-stats.json'))
|
res.json(readJson('system-stats.json'))
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/submissions', (req, res) => {
|
router.get('/challenge/submissions', (req, res) => {
|
||||||
res.json(readJson('submissions.json'))
|
res.json(readJson('submissions.json'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user