Add rules and patterns for working with Challenge Platform. Introduce guidelines for Chakra UI usage, localStorage management, context updates, code formatting, and multi-step forms. Enhance user experience with a dynamic queue checking system and progress bar. Include a checklist for commits to ensure code quality and consistency.
This commit is contained in:
@@ -48,6 +48,7 @@ export interface QueueStatus {
|
||||
submission?: ChallengeSubmission
|
||||
error?: string
|
||||
position?: number
|
||||
initialPosition?: number
|
||||
}
|
||||
|
||||
export interface TaskAttempt {
|
||||
|
||||
@@ -9,7 +9,7 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
||||
const { nickname, logout } = useChallenge()
|
||||
const { nickname, workplaceNumber, logout } = useChallenge()
|
||||
|
||||
if (!nickname) return null
|
||||
|
||||
@@ -21,6 +21,14 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
||||
{chainName || 'Challenge Platform'}
|
||||
</Heading>
|
||||
<Flex gap={3} align="center" mt={1}>
|
||||
{workplaceNumber && (
|
||||
<>
|
||||
<Text fontSize="sm" color="gray.600" fontWeight="medium">
|
||||
Место №{workplaceNumber}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.400">•</Text>
|
||||
</>
|
||||
)}
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{nickname}
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -10,12 +10,41 @@ import {
|
||||
|
||||
import { useChallenge } from '../context/ChallengeContext'
|
||||
|
||||
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
|
||||
|
||||
export const LoginForm = () => {
|
||||
const [step, setStep] = useState<'workplace' | 'fio'>('workplace')
|
||||
const [workplaceNumber, setWorkplaceNumber] = useState('')
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const { login, isAuthLoading } = useChallenge()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
// Проверяем, есть ли сохранённый номер рабочего места
|
||||
useEffect(() => {
|
||||
const savedWorkplace = localStorage.getItem(WORKPLACE_NUMBER_KEY)
|
||||
if (savedWorkplace) {
|
||||
setWorkplaceNumber(savedWorkplace)
|
||||
setStep('fio')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWorkplaceSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const trimmedWorkplace = workplaceNumber.trim()
|
||||
if (!trimmedWorkplace) {
|
||||
setError('Пожалуйста, введите номер рабочего места')
|
||||
return
|
||||
}
|
||||
|
||||
// Сохраняем номер рабочего места сразу
|
||||
localStorage.setItem(WORKPLACE_NUMBER_KEY, trimmedWorkplace)
|
||||
setWorkplaceNumber(trimmedWorkplace)
|
||||
setError('')
|
||||
setStep('fio')
|
||||
}
|
||||
|
||||
const handleFioSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const trimmedName = fullName.trim()
|
||||
@@ -31,13 +60,76 @@ export const LoginForm = () => {
|
||||
|
||||
try {
|
||||
setError('')
|
||||
await login(trimmedName)
|
||||
await login(trimmedName, workplaceNumber.trim())
|
||||
} catch (err) {
|
||||
setError('Произошла ошибка при входе. Попробуйте снова.')
|
||||
console.error('Login error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'workplace') {
|
||||
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"
|
||||
>
|
||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||
<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={handleWorkplaceSubmit}>
|
||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<Text fontWeight="medium" mb={2}>
|
||||
Номер рабочего места
|
||||
</Text>
|
||||
<Input
|
||||
value={workplaceNumber}
|
||||
onChange={(e) => setWorkplaceNumber(e.target.value)}
|
||||
placeholder="Например: 1"
|
||||
size="lg"
|
||||
autoFocus
|
||||
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||
isInvalid={!!error}
|
||||
/>
|
||||
{error && (
|
||||
<Text color="red.500" fontSize="sm" mt={2}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="teal"
|
||||
size="lg"
|
||||
// @ts-expect-error Chakra UI v2 uses isDisabled
|
||||
isDisabled={!workplaceNumber.trim()}
|
||||
>
|
||||
Продолжить
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||
<Box
|
||||
@@ -49,17 +141,25 @@ export const LoginForm = () => {
|
||||
maxW="480px"
|
||||
w="full"
|
||||
>
|
||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg" color="teal.600" mb={2}>
|
||||
Challenge Platform
|
||||
</Heading>
|
||||
<Text color="gray.600">
|
||||
Добро пожаловать! Введите ваше ФИО для начала работы
|
||||
<Text color="gray.600" fontWeight="medium">
|
||||
Рабочее место: №{workplaceNumber}
|
||||
</Text>
|
||||
<Text color="gray.500" fontSize="sm" mt={1}>
|
||||
(номер сохранён)
|
||||
</Text>
|
||||
<Text color="gray.600" mt={3}>
|
||||
Введите ваше ФИО для начала работы
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleFioSubmit}>
|
||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<Text fontWeight="medium" mb={2}>
|
||||
@@ -71,6 +171,7 @@ export const LoginForm = () => {
|
||||
placeholder="Иванов Иван Иванович"
|
||||
size="lg"
|
||||
autoFocus
|
||||
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||
isInvalid={!!error}
|
||||
/>
|
||||
{error && (
|
||||
@@ -80,15 +181,21 @@ export const LoginForm = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="teal"
|
||||
size="lg"
|
||||
isLoading={isAuthLoading}
|
||||
isDisabled={!fullName.trim()}
|
||||
>
|
||||
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
|
||||
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
|
||||
Войти
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStep('workplace')
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
Изменить рабочее место
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</VStack>
|
||||
@@ -96,4 +203,3 @@ export const LoginForm = () => {
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
HStack,
|
||||
Spinner,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
@@ -33,6 +32,26 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||
const isAccepted = finalSubmission?.status === 'accepted'
|
||||
const needsRevision = finalSubmission?.status === 'needs_revision'
|
||||
|
||||
// Вычисляем прогресс проверки (0-100%)
|
||||
const checkingProgress = (() => {
|
||||
if (!queueStatus) return 0
|
||||
|
||||
const initial = queueStatus.initialPosition || 3
|
||||
const current = queueStatus.position || 0
|
||||
|
||||
if (queueStatus.status === 'in_progress') return 90 // Почти готово
|
||||
if (current === 0) return 90
|
||||
|
||||
// От 0% до 80% по мере движения в очереди
|
||||
const progress = ((initial - current) / initial) * 80
|
||||
return Math.max(10, progress) // Минимум 10% чтобы было видно
|
||||
})()
|
||||
|
||||
// Сбрасываем состояние при смене задания
|
||||
useEffect(() => {
|
||||
setLastResult(null)
|
||||
}, [task.id])
|
||||
|
||||
// Обновляем сохраненный результат только когда получаем новый
|
||||
useEffect(() => {
|
||||
if (finalSubmission) {
|
||||
@@ -230,14 +249,59 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||
{/* Статус проверки и результат - фиксированное место */}
|
||||
<Box minH="80px">
|
||||
{queueStatus && !finalSubmission ? (
|
||||
<Box borderWidth="1px" borderRadius="md" borderColor="blue.200" bg="blue.50" p={2}>
|
||||
<HStack gap={2}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text fontSize="sm" fontWeight="medium" color="blue.700">
|
||||
{queueStatus.status === 'waiting' ? 'Ожидание в очереди...' : 'Проверяем решение...'}
|
||||
{typeof queueStatus.position === 'number' && queueStatus.position > 0 && ` (позиция: ${queueStatus.position})`}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Box
|
||||
borderWidth="2px"
|
||||
borderRadius="lg"
|
||||
borderColor="blue.300"
|
||||
bg="blue.50"
|
||||
p={4}
|
||||
>
|
||||
<VStack gap={3} align="stretch">
|
||||
<HStack justify="center">
|
||||
<Text fontSize="lg" fontWeight="bold" color="blue.700">
|
||||
Проверяем решение...
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Box>
|
||||
{/* Кастомный прогресс-бар */}
|
||||
<Box
|
||||
bg="blue.100"
|
||||
borderRadius="md"
|
||||
h="24px"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
<Box
|
||||
bg="blue.500"
|
||||
h="full"
|
||||
w={`${checkingProgress}%`}
|
||||
transition="width 0.5s ease"
|
||||
position="relative"
|
||||
>
|
||||
{/* Анимированные полоски */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bgGradient="linear(to-r, transparent, blue.400, transparent)"
|
||||
animation="shimmer 2s infinite"
|
||||
css={{
|
||||
'@keyframes shimmer': {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="blue.600" textAlign="center" mt={2} fontWeight="medium">
|
||||
{checkingProgress < 50 ? 'Ожидание в очереди' : checkingProgress < 90 ? 'Начинаем проверку' : 'Почти готово'}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
) : showAccepted ? (
|
||||
<Box borderWidth="1px" borderRadius="md" borderColor="green.300" bg="green.50" p={3}>
|
||||
|
||||
@@ -56,13 +56,14 @@ class ChallengeCache {
|
||||
interface ChallengeContextValue {
|
||||
userId: string | null
|
||||
nickname: string | null
|
||||
workplaceNumber: string | null
|
||||
stats: UserStats | null
|
||||
personalDashboard: PersonalDashboard | null
|
||||
chains: ChallengeChain[]
|
||||
isAuthenticated: boolean
|
||||
isAuthLoading: boolean
|
||||
isStatsLoading: boolean
|
||||
login: (nickname: string) => Promise<void>
|
||||
login: (nickname: string, workplaceNumber: string) => Promise<void>
|
||||
logout: () => void
|
||||
refreshStats: () => Promise<void>
|
||||
eventEmitter: ChallengeEventEmitter
|
||||
@@ -78,13 +79,14 @@ const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefi
|
||||
|
||||
const USER_ID_KEY = 'challengeUserId'
|
||||
const USER_NICKNAME_KEY = 'challengeNickname'
|
||||
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
|
||||
|
||||
export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
const cacheRef = useRef(new ChallengeCache())
|
||||
const metricsCollector = useMemo(() => new MetricsCollector(), [])
|
||||
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
|
||||
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
|
||||
const pollingManager = useMemo(() => new PollingManager(), [])
|
||||
const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), [])
|
||||
|
||||
const [userId, setUserId] = useState<string | null>(() =>
|
||||
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
|
||||
@@ -92,6 +94,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
const [nickname, setNickname] = useState<string | null>(() =>
|
||||
isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null,
|
||||
)
|
||||
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() =>
|
||||
isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null,
|
||||
)
|
||||
const [stats, setStats] = useState<UserStats | null>(null)
|
||||
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
||||
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
||||
@@ -151,14 +156,16 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [refreshStats, userId])
|
||||
|
||||
const login = useCallback(
|
||||
async (nicknameValue: string) => {
|
||||
async (nicknameValue: string, workplaceNumberValue: string) => {
|
||||
const response = await authUser({ nickname: nicknameValue }).unwrap()
|
||||
setUserId(response.userId)
|
||||
setNickname(nicknameValue)
|
||||
setWorkplaceNumber(workplaceNumberValue)
|
||||
|
||||
if (isBrowser()) {
|
||||
window.localStorage.setItem(USER_ID_KEY, response.userId)
|
||||
window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue)
|
||||
window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue)
|
||||
}
|
||||
|
||||
cacheRef.current.clear('chains')
|
||||
@@ -170,6 +177,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
const logout = useCallback(() => {
|
||||
setUserId(null)
|
||||
setNickname(null)
|
||||
setWorkplaceNumber(null)
|
||||
setStats(null)
|
||||
setPersonalDashboard(null)
|
||||
cacheRef.current.clear()
|
||||
@@ -177,6 +185,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
if (isBrowser()) {
|
||||
window.localStorage.removeItem(USER_ID_KEY)
|
||||
window.localStorage.removeItem(USER_NICKNAME_KEY)
|
||||
window.localStorage.removeItem(WORKPLACE_NUMBER_KEY)
|
||||
window.localStorage.removeItem('challengeSelectedChainId')
|
||||
window.localStorage.removeItem('challengeSelectedTaskId')
|
||||
}
|
||||
@@ -188,6 +197,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
() => ({
|
||||
userId,
|
||||
nickname,
|
||||
workplaceNumber,
|
||||
stats,
|
||||
personalDashboard,
|
||||
chains,
|
||||
@@ -217,6 +227,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
logout,
|
||||
metricsCollector,
|
||||
nickname,
|
||||
workplaceNumber,
|
||||
personalDashboard,
|
||||
pollingManager,
|
||||
refreshStats,
|
||||
|
||||
@@ -78,14 +78,19 @@ export const MainPage = () => {
|
||||
const unsubscribe = eventEmitter.on('submission_completed', (event) => {
|
||||
const submission = (event.data as { submission?: { status: string; attemptNumber: number } })?.submission
|
||||
const accepted = submission?.status === 'accepted'
|
||||
const title = accepted ? 'Задание принято' : 'Задание требует доработки'
|
||||
|
||||
if (!accepted) {
|
||||
return
|
||||
}
|
||||
|
||||
const title = 'Задание принято'
|
||||
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
|
||||
|
||||
if (notificationTimeoutRef.current) {
|
||||
window.clearTimeout(notificationTimeoutRef.current)
|
||||
}
|
||||
|
||||
setNotification({ status: accepted ? 'success' : 'warning', title, description })
|
||||
// setNotification({ status: 'success', title, description })
|
||||
notificationTimeoutRef.current = window.setTimeout(() => setNotification(null), 4000)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user