Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08b654bd4d | |||
| cbf411cd54 | |||
| f4e85fe980 | |||
| 3c1a235832 | |||
| 1a52901b90 | |||
| c9bbe83bbb | |||
| 9f5a236c7c | |||
| dac2ba4078 | |||
| f774cd27d8 | |||
| 259b1c9353 |
420
claude.md
Normal file
420
claude.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Правила работы с Challenge Platform
|
||||
|
||||
Этот документ содержит важные правила и паттерны для работы с кодовой базой проекта Challenge Platform.
|
||||
|
||||
## 🎯 Chakra UI - Специфика проекта
|
||||
|
||||
### TypeScript и Chakra UI Props
|
||||
|
||||
Проект использует Chakra UI v2/v3 с особенностями типизации. **ВАЖНО:**
|
||||
|
||||
#### ✅ Правильное подавление ошибок TypeScript:
|
||||
|
||||
```tsx
|
||||
// ❌ НЕПРАВИЛЬНО - комментарий на отдельной строке НЕ работает:
|
||||
{/* @ts-expect-error Chakra UI v2 uses isDisabled */}
|
||||
<Button
|
||||
isDisabled={someCondition}
|
||||
>
|
||||
Кнопка
|
||||
</Button>
|
||||
|
||||
// ✅ ПРАВИЛЬНО - комментарий перед элементом + все props на одной строке:
|
||||
{/* @ts-expect-error Chakra UI v2 uses isDisabled */}
|
||||
<Button isDisabled={someCondition}>Кнопка</Button>
|
||||
|
||||
// ✅ ПРАВИЛЬНО - inline комментарий после prop:
|
||||
<Input
|
||||
value={value}
|
||||
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||
isInvalid={!!error}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Частые props требующие подавления:
|
||||
|
||||
- `isDisabled` (Button, Input)
|
||||
- `isLoading` (Button)
|
||||
- `isInvalid` (Input)
|
||||
- `spacing` (VStack, HStack)
|
||||
|
||||
### VStack и HStack
|
||||
|
||||
```tsx
|
||||
// ✅ ПРАВИЛЬНО:
|
||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* content */}
|
||||
</VStack>
|
||||
```
|
||||
|
||||
## 💾 LocalStorage - Ключи и паттерны
|
||||
|
||||
### Константы для ключей
|
||||
|
||||
**ВСЕГДА** используйте константы для ключей localStorage:
|
||||
|
||||
```tsx
|
||||
// ✅ ПРАВИЛЬНО:
|
||||
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
|
||||
const USER_ID_KEY = 'challengeUserId'
|
||||
const USER_NICKNAME_KEY = 'challengeNickname'
|
||||
const SELECTED_CHAIN_KEY = 'challengeSelectedChainId'
|
||||
const SELECTED_TASK_KEY = 'challengeSelectedTaskId'
|
||||
|
||||
// Префикс 'challenge' для всех ключей проекта
|
||||
```
|
||||
|
||||
### Синхронизация localStorage и Context
|
||||
|
||||
Если добавляете новое поле в Context, которое должно сохраняться:
|
||||
|
||||
1. **Добавьте константу** для ключа
|
||||
2. **Инициализируйте state** из localStorage:
|
||||
```tsx
|
||||
const [field, setField] = useState<string | null>(() =>
|
||||
isBrowser() ? window.localStorage.getItem(FIELD_KEY) : null,
|
||||
)
|
||||
```
|
||||
3. **Сохраняйте в localStorage** при изменении (login)
|
||||
4. **Очищайте из localStorage** при выходе (logout)
|
||||
5. **Добавьте в интерфейс** ChallengeContextValue
|
||||
6. **Добавьте в useMemo** (value и dependencies)
|
||||
|
||||
## 🔄 Context - Паттерн обновления
|
||||
|
||||
### Добавление нового поля в ChallengeContext
|
||||
|
||||
Пример: добавление `workplaceNumber`
|
||||
|
||||
**Шаг 1:** Интерфейс
|
||||
```tsx
|
||||
interface ChallengeContextValue {
|
||||
// ... существующие поля
|
||||
workplaceNumber: string | null // ← добавить
|
||||
}
|
||||
```
|
||||
|
||||
**Шаг 2:** Константа
|
||||
```tsx
|
||||
const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber'
|
||||
```
|
||||
|
||||
**Шаг 3:** State с инициализацией
|
||||
```tsx
|
||||
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() =>
|
||||
isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null,
|
||||
)
|
||||
```
|
||||
|
||||
**Шаг 4:** Обновление login
|
||||
```tsx
|
||||
const login = useCallback(
|
||||
async (nicknameValue: string, workplaceNumberValue: string) => {
|
||||
// ... существующий код
|
||||
setWorkplaceNumber(workplaceNumberValue)
|
||||
|
||||
if (isBrowser()) {
|
||||
// ... существующие setItem
|
||||
window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue)
|
||||
}
|
||||
},
|
||||
[authUser, refreshStatsById],
|
||||
)
|
||||
```
|
||||
|
||||
**Шаг 5:** Обновление logout
|
||||
```tsx
|
||||
const logout = useCallback(() => {
|
||||
// ... существующие setNull
|
||||
setWorkplaceNumber(null)
|
||||
|
||||
if (isBrowser()) {
|
||||
// ... существующие removeItem
|
||||
window.localStorage.removeItem(WORKPLACE_NUMBER_KEY)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
**Шаг 6:** Добавить в value
|
||||
```tsx
|
||||
const value = useMemo<ChallengeContextValue>(
|
||||
() => ({
|
||||
// ... существующие поля
|
||||
workplaceNumber, // ← добавить
|
||||
}),
|
||||
[
|
||||
// ... существующие зависимости
|
||||
workplaceNumber, // ← добавить в dependencies
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## 📝 Форматирование кода
|
||||
|
||||
### Отступы
|
||||
- **2 пробела** для отступов
|
||||
- Никаких табов
|
||||
- JSX атрибуты выравниваются по отступам родителя + 2 пробела
|
||||
|
||||
### Комментарии на русском
|
||||
```tsx
|
||||
// ✅ ПРАВИЛЬНО:
|
||||
// Проверяем, есть ли сохранённый номер рабочего места
|
||||
useEffect(() => {
|
||||
const savedWorkplace = localStorage.getItem(WORKPLACE_NUMBER_KEY)
|
||||
// ...
|
||||
}, [])
|
||||
```
|
||||
|
||||
## 🔐 Паттерн двухшаговой формы
|
||||
|
||||
Если нужна форма с несколькими шагами:
|
||||
|
||||
```tsx
|
||||
export const MultiStepForm = () => {
|
||||
const [step, setStep] = useState<'step1' | 'step2'>('step1')
|
||||
|
||||
// Проверка сохранённых данных
|
||||
useEffect(() => {
|
||||
const savedData = localStorage.getItem(SAVED_DATA_KEY)
|
||||
if (savedData) {
|
||||
setData(savedData)
|
||||
setStep('step2') // Пропускаем первый шаг
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStep1Submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Сохраняем СРАЗУ после валидации
|
||||
localStorage.setItem(SAVED_DATA_KEY, trimmedData)
|
||||
setStep('step2')
|
||||
}
|
||||
|
||||
// Условный рендеринг
|
||||
if (step === 'step1') {
|
||||
return <Step1Form />
|
||||
}
|
||||
|
||||
return <Step2Form />
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ Важные моменты:
|
||||
1. Сохраняйте данные **сразу** после валидации, не ждите финального submit
|
||||
2. Проверяйте localStorage при монтировании компонента
|
||||
3. Пропускайте шаги, если данные уже есть
|
||||
4. Давайте возможность вернуться и изменить данные
|
||||
|
||||
## 🎨 Header - Паттерн отображения информации
|
||||
|
||||
Порядок отображения в Header:
|
||||
```
|
||||
Место №{number} • {nickname} • {taskProgress}
|
||||
```
|
||||
|
||||
Важные элементы отмечаем `fontWeight="medium"`:
|
||||
```tsx
|
||||
<Text fontSize="sm" color="gray.600" fontWeight="medium">
|
||||
Место №{workplaceNumber}
|
||||
</Text>
|
||||
```
|
||||
|
||||
## 🐛 Частые ошибки и решения
|
||||
|
||||
### ❌ Ошибка: "Property 'spacing' does not exist"
|
||||
**Решение:** Добавить `@ts-expect-error` комментарий перед VStack/HStack
|
||||
|
||||
### ❌ Ошибка: "Unused '@ts-expect-error' directive"
|
||||
**Причина:** Комментарий расположен неправильно
|
||||
**Решение:** Поместить комментарий **непосредственно** перед элементом с ошибкой, и все props элемента должны быть на одной строке или с inline комментариями
|
||||
|
||||
### ❌ Ошибка: Данные не сохраняются между сессиями
|
||||
**Проверить:**
|
||||
1. Используется ли `localStorage.setItem`?
|
||||
2. Инициализируется ли state из localStorage?
|
||||
3. Очищается ли в logout?
|
||||
4. Синхронизированы ли ключи (одинаковые константы)?
|
||||
|
||||
### ❌ Ошибка: Context не обновляется
|
||||
**Проверить:**
|
||||
1. Добавлено ли новое поле в `useMemo` value?
|
||||
2. Добавлено ли новое поле в dependencies массив `useMemo`?
|
||||
3. Добавлено ли в интерфейс `ChallengeContextValue`?
|
||||
|
||||
## 📚 Структура проекта
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Компоненты
|
||||
│ ├── LoginForm.tsx # Форма входа (может быть многошаговой)
|
||||
│ ├── Header.tsx # Шапка с информацией о пользователе
|
||||
│ └── ...
|
||||
├── context/ # React Context
|
||||
│ └── ChallengeContext.tsx # Главный контекст приложения
|
||||
├── pages/ # Страницы
|
||||
│ └── main/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## ✅ Чеклист перед коммитом
|
||||
|
||||
- [ ] Нет linter errors (проверить `read_lints`)
|
||||
- [ ] TypeScript ошибки подавлены правильно
|
||||
- [ ] LocalStorage ключи используют константы
|
||||
- [ ] Новые поля Context добавлены во все нужные места
|
||||
- [ ] Комментарии на русском языке
|
||||
- [ ] Отступы - 2 пробела
|
||||
- [ ] Данные сохраняются и восстанавливаются из localStorage
|
||||
|
||||
## 🎯 Динамическая очередь проверки с прогресс-баром
|
||||
|
||||
### Симуляция очереди в API (stubs/api/index.js)
|
||||
|
||||
Для лучшего UX очередь проверки показывает прогресс через прогресс-бар.
|
||||
|
||||
**Паттерн реализации:**
|
||||
|
||||
```javascript
|
||||
// Таймауты: 100ms для быстрой работы в dev
|
||||
const timer = (time = 100) => (req, res, next) => setTimeout(next, time)
|
||||
|
||||
// Храним состояние для каждого queueId
|
||||
const queueStates = {}
|
||||
|
||||
router.get('/challenge/check-status/:queueId', (req, res) => {
|
||||
const queueId = req.params.queueId
|
||||
|
||||
// Инициализируем с позицией 3
|
||||
if (!queueStates[queueId]) {
|
||||
queueStates[queueId] = {
|
||||
position: 3,
|
||||
initialPosition: 3, // Для расчёта прогресса
|
||||
pollCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
const state = queueStates[queueId]
|
||||
state.pollCount++
|
||||
|
||||
// Каждый запрос уменьшаем позицию (быстро)
|
||||
if (state.pollCount >= 1 && state.position > 0) {
|
||||
state.position--
|
||||
state.pollCount = 0
|
||||
}
|
||||
|
||||
// Когда позиция 0 → возвращаем результат проверки
|
||||
if (state.position === 0 && state.pollCount >= 1) {
|
||||
delete queueStates[queueId] // Очищаем
|
||||
return res.json({ status: 'completed', submission: {...} })
|
||||
}
|
||||
|
||||
// Возвращаем текущую позицию + начальную для прогресса
|
||||
return res.json({
|
||||
status: state.position > 0 ? 'waiting' : 'in_progress',
|
||||
position: state.position,
|
||||
initialPosition: state.initialPosition // ← Важно!
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### UI с прогресс-баром
|
||||
|
||||
**Новый подход:** Вместо отображения номера в очереди показываем прогресс-бар.
|
||||
|
||||
**Расчёт прогресса:**
|
||||
|
||||
```tsx
|
||||
const checkingProgress = (() => {
|
||||
if (!queueStatus) return 0
|
||||
|
||||
const initial = queueStatus.initialPosition || 3
|
||||
const current = queueStatus.position || 0
|
||||
|
||||
if (queueStatus.status === 'in_progress') return 90 // Почти готово
|
||||
|
||||
// От 0% до 80% по мере движения в очереди
|
||||
const progress = ((initial - current) / initial) * 80
|
||||
return Math.max(10, progress) // Минимум 10% чтобы было видно
|
||||
})()
|
||||
```
|
||||
|
||||
**Кастомный прогресс-бар с анимацией:**
|
||||
|
||||
```tsx
|
||||
<Box bg="blue.100" borderRadius="md" h="24px" overflow="hidden">
|
||||
<Box
|
||||
bg="blue.500"
|
||||
h="full"
|
||||
w={`${checkingProgress}%`}
|
||||
transition="width 0.5s ease"
|
||||
position="relative"
|
||||
>
|
||||
{/* Анимированный блик */}
|
||||
<Box
|
||||
position="absolute"
|
||||
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>
|
||||
```
|
||||
|
||||
**Текст под прогресс-баром:**
|
||||
```tsx
|
||||
<Text fontSize="xs" color="blue.600" textAlign="center" mt={2}>
|
||||
{checkingProgress < 50 ? 'Ожидание в очереди' :
|
||||
checkingProgress < 90 ? 'Начинаем проверку' :
|
||||
'Почти готово'}
|
||||
</Text>
|
||||
```
|
||||
|
||||
### ⚠️ Важно
|
||||
|
||||
- **Таймауты в dev:** 100ms для быстрой работы. На prod бэкенд будет быстрее
|
||||
- **Прогресс:** 0-80% = движение в очереди, 80-90% = начало проверки, 90%+ = финализация
|
||||
- **Не показываем номер** в очереди - только прогресс-бар
|
||||
- **Текст:** простой "Проверяем решение..." вместо деталей
|
||||
- **Анимация shimmer:** добавляет ощущение процесса
|
||||
- Полинг: интервал 2 секунды (`PollingManager`)
|
||||
- **КРИТИЧНО:** При смене задания сбрасывайте все локальные состояния
|
||||
|
||||
### Сброс состояния при смене задания
|
||||
|
||||
**ОБЯЗАТЕЛЬНО** добавляйте useEffect для сброса локальных состояний:
|
||||
|
||||
```tsx
|
||||
// Сбрасываем состояние при смене задания
|
||||
useEffect(() => {
|
||||
setLastResult(null)
|
||||
setPrevPosition(null)
|
||||
setPositionChanged(false)
|
||||
// ... любые другие локальные состояния
|
||||
}, [task.id]) // ← зависимость от task.id
|
||||
```
|
||||
|
||||
**Почему это важно:**
|
||||
- Без сброса плашки успеха/ошибки от предыдущего задания остаются видимыми
|
||||
- Пользователь видит некорректную информацию
|
||||
- Состояние очереди "протекает" между заданиями
|
||||
|
||||
## 🎓 Философия проекта
|
||||
|
||||
1. **Удобство пользователя превыше всего** - сохраняем данные, не переспрашиваем
|
||||
2. **Типобезопасность** - используем TypeScript, подавляем ошибки осознанно
|
||||
3. **Консистентность** - следуем установленным паттернам
|
||||
4. **Персистентность** - важные данные (номер места, выбор цепочки) сохраняются в localStorage
|
||||
5. **Обратная связь** - пользователь всегда видит что происходит (позиция в очереди, прогресс проверки)
|
||||
|
||||
---
|
||||
|
||||
**Версия:** 1.1
|
||||
**Дата:** 13.12.2025
|
||||
**Автор:** Claude (на основе опыта работы с проектом)
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "challenge",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "challenge",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@brojs/cli": "^1.9.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "challenge",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"description": "",
|
||||
"main": "./src/index.tsx",
|
||||
"scripts": {
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface QueueStatus {
|
||||
submission?: ChallengeSubmission
|
||||
error?: string
|
||||
position?: number
|
||||
initialPosition?: number
|
||||
}
|
||||
|
||||
export interface TaskAttempt {
|
||||
|
||||
@@ -17,6 +17,14 @@ const getNavPath = (key: string, fallback: string) => {
|
||||
|
||||
export const URLs = {
|
||||
baseUrl,
|
||||
// Основные маршруты
|
||||
workplace: makeUrl('/workplace'),
|
||||
login: makeUrl('/login'),
|
||||
chains: makeUrl('/chains'),
|
||||
chain: (chainId: string) => makeUrl(`/chain/${chainId}`),
|
||||
task: (chainId: string, taskId: string) => makeUrl(`/chain/${chainId}/task/${taskId}`),
|
||||
completed: (chainId: string) => makeUrl(`/completed/${chainId}`),
|
||||
// Старые маршруты
|
||||
auth: {
|
||||
url: makeUrl(navs[`link.${pkg.name}.auth`]),
|
||||
isOn: Boolean(navs[`link.${pkg.name}.auth`]),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react'
|
||||
|
||||
import { useChallenge } from '../context/ChallengeContext'
|
||||
import { URLs } from '../__data__/urls'
|
||||
|
||||
interface HeaderProps {
|
||||
chainName?: string
|
||||
@@ -9,7 +11,13 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
||||
const { nickname, logout } = useChallenge()
|
||||
const navigate = useNavigate()
|
||||
const { nickname, workplaceNumber, logout } = useChallenge()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate(URLs.workplace)
|
||||
}
|
||||
|
||||
if (!nickname) return null
|
||||
|
||||
@@ -21,6 +29,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>
|
||||
@@ -34,7 +50,7 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Button onClick={logout} variant="ghost" size="sm">
|
||||
<Button onClick={handleLogout} variant="ghost" size="sm">
|
||||
Выйти
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
HStack,
|
||||
Spinner,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
@@ -22,7 +21,7 @@ interface TaskWorkspaceProps {
|
||||
|
||||
export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||
const { refreshStats } = useChallenge()
|
||||
const { result, setResult, submit, reset, queueStatus, finalSubmission, isSubmitting } = useSubmission({
|
||||
const { result, setResult, submit, queueStatus, finalSubmission, isSubmitting } = useSubmission({
|
||||
taskId: task.id,
|
||||
})
|
||||
|
||||
@@ -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})`}
|
||||
<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}>
|
||||
@@ -300,12 +364,18 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<HStack justify="flex-end" gap={2}>
|
||||
<HStack justify="space-between" gap={2}>
|
||||
{!isAccepted && (
|
||||
<>
|
||||
{/* @ts-expect-error Chakra UI v2 uses isDisabled */}
|
||||
<Button onClick={reset} variant="ghost" size="sm" isDisabled={isChecking}>
|
||||
Сбросить
|
||||
<Button
|
||||
onClick={onTaskComplete}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
colorScheme="gray"
|
||||
// @ts-expect-error Chakra UI v2 uses isDisabled
|
||||
isDisabled={isChecking}
|
||||
>
|
||||
Пропустить
|
||||
</Button>
|
||||
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
|
||||
<Button onClick={submit} colorScheme="teal" size="sm" isLoading={isChecking} isDisabled={!result.trim() || isChecking}>
|
||||
|
||||
@@ -19,8 +19,7 @@ import { BehaviorTracker, MetricsCollector, buildPersonalDashboard } from '../ut
|
||||
import { ChallengeEventEmitter } from '../utils/events'
|
||||
import { clearDraft, loadDraft, saveDraft } from '../utils/drafts'
|
||||
import { PollingManager } from '../utils/polling'
|
||||
|
||||
const isBrowser = () => typeof window !== 'undefined'
|
||||
import { storage } from '../utils/storage'
|
||||
|
||||
class ChallengeCache {
|
||||
private cache = new Map<string, { data: unknown; expires: number }>()
|
||||
@@ -56,13 +55,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
|
||||
@@ -76,22 +76,16 @@ interface ChallengeContextValue {
|
||||
|
||||
const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefined)
|
||||
|
||||
const USER_ID_KEY = 'challengeUserId'
|
||||
const USER_NICKNAME_KEY = 'challengeNickname'
|
||||
|
||||
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,
|
||||
)
|
||||
const [nickname, setNickname] = useState<string | null>(() =>
|
||||
isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null,
|
||||
)
|
||||
const [userId, setUserId] = useState<string | null>(() => storage.getUserId())
|
||||
const [nickname, setNickname] = useState<string | null>(() => storage.getNickname())
|
||||
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() => storage.getWorkplaceNumber())
|
||||
const [stats, setStats] = useState<UserStats | null>(null)
|
||||
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
||||
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
||||
@@ -151,15 +145,15 @@ 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)
|
||||
}
|
||||
storage.setUserId(response.userId)
|
||||
storage.setNickname(nicknameValue)
|
||||
storage.setWorkplaceNumber(workplaceNumberValue)
|
||||
|
||||
cacheRef.current.clear('chains')
|
||||
await refreshStatsById(response.userId)
|
||||
@@ -170,16 +164,13 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
const logout = useCallback(() => {
|
||||
setUserId(null)
|
||||
setNickname(null)
|
||||
setWorkplaceNumber(null)
|
||||
setStats(null)
|
||||
setPersonalDashboard(null)
|
||||
cacheRef.current.clear()
|
||||
|
||||
if (isBrowser()) {
|
||||
window.localStorage.removeItem(USER_ID_KEY)
|
||||
window.localStorage.removeItem(USER_NICKNAME_KEY)
|
||||
window.localStorage.removeItem('challengeSelectedChainId')
|
||||
window.localStorage.removeItem('challengeSelectedTaskId')
|
||||
}
|
||||
// Очищаем всё из localStorage
|
||||
storage.clearAll()
|
||||
}, [])
|
||||
|
||||
const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading
|
||||
@@ -188,6 +179,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
() => ({
|
||||
userId,
|
||||
nickname,
|
||||
workplaceNumber,
|
||||
stats,
|
||||
personalDashboard,
|
||||
chains,
|
||||
@@ -217,6 +209,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
logout,
|
||||
metricsCollector,
|
||||
nickname,
|
||||
workplaceNumber,
|
||||
personalDashboard,
|
||||
pollingManager,
|
||||
refreshStats,
|
||||
|
||||
@@ -1,24 +1,107 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { URLs } from './__data__/urls'
|
||||
import { MainPage } from './pages'
|
||||
import {
|
||||
WorkplacePage,
|
||||
LoginPage,
|
||||
ChainsPage,
|
||||
TaskPage,
|
||||
CompletedPage
|
||||
} from './pages'
|
||||
import { storage } from './utils/storage'
|
||||
|
||||
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||
)
|
||||
|
||||
// Компонент для редиректа на нужную страницу
|
||||
const IndexRedirect = () => {
|
||||
const workplaceNumber = storage.getWorkplaceNumber()
|
||||
const nickname = storage.getNickname()
|
||||
const chainId = storage.getSelectedChainId()
|
||||
const taskId = storage.getSelectedTaskId()
|
||||
|
||||
// Если есть сохранённое задание - туда
|
||||
if (nickname && chainId && taskId) {
|
||||
return <Navigate to={URLs.task(chainId, taskId)} replace />
|
||||
}
|
||||
// Если авторизован - к цепочкам
|
||||
if (nickname) {
|
||||
return <Navigate to={URLs.chains} replace />
|
||||
}
|
||||
// Если есть номер места - к логину
|
||||
if (workplaceNumber) {
|
||||
return <Navigate to={URLs.login} replace />
|
||||
}
|
||||
// Иначе - к вводу места
|
||||
return <Navigate to={URLs.workplace} replace />
|
||||
}
|
||||
|
||||
export const Dashboard = () => {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Главная - редирект */}
|
||||
<Route
|
||||
path={URLs.baseUrl}
|
||||
element={<IndexRedirect />}
|
||||
/>
|
||||
|
||||
{/* Ввод номера рабочего места */}
|
||||
<Route
|
||||
path={URLs.workplace}
|
||||
element={
|
||||
<PageWrapper>
|
||||
<MainPage />
|
||||
<WorkplacePage />
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Ввод ФИО */}
|
||||
<Route
|
||||
path={URLs.login}
|
||||
element={
|
||||
<PageWrapper>
|
||||
<LoginPage />
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Выбор цепочки */}
|
||||
<Route
|
||||
path={URLs.chains}
|
||||
element={
|
||||
<PageWrapper>
|
||||
<ChainsPage />
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Задание */}
|
||||
<Route
|
||||
path={`${URLs.baseUrl}/chain/:chainId/task/:taskId`}
|
||||
element={
|
||||
<PageWrapper>
|
||||
<TaskPage />
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Завершение цепочки */}
|
||||
<Route
|
||||
path={`${URLs.baseUrl}/completed/:chainId`}
|
||||
element={
|
||||
<PageWrapper>
|
||||
<CompletedPage />
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route
|
||||
path="*"
|
||||
element={<IndexRedirect />}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../__data__/api/api'
|
||||
import type { ChallengeSubmission, QueueStatus } from '../__data__/types'
|
||||
import { useChallenge } from '../context/ChallengeContext'
|
||||
import { loadFinalAnswer, saveFinalAnswer } from '../utils/drafts'
|
||||
|
||||
interface UseSubmissionArgs {
|
||||
taskId: string
|
||||
@@ -43,13 +44,24 @@ export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult =
|
||||
|
||||
useEffect(() => {
|
||||
behaviorTracker.reset()
|
||||
|
||||
// Сначала проверяем финальный ответ (если задание уже решалось)
|
||||
const finalAnswer = loadFinalAnswer(taskId)
|
||||
if (finalAnswer) {
|
||||
setResultState(finalAnswer)
|
||||
behaviorTracker.markDraftUsed()
|
||||
} else {
|
||||
// Если финального ответа нет, проверяем черновик
|
||||
const draft = loadDraft(taskId)
|
||||
if (draft) {
|
||||
setResultState(draft)
|
||||
behaviorTracker.markDraftUsed()
|
||||
} else {
|
||||
// Если ничего нет - пустое поле
|
||||
setResultState('')
|
||||
}
|
||||
}
|
||||
|
||||
pollingManager.stop()
|
||||
setQueueId(null)
|
||||
setQueueStatus(null)
|
||||
@@ -114,6 +126,9 @@ export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult =
|
||||
})
|
||||
|
||||
setFinalSubmission(status.submission)
|
||||
// Сохраняем финальный ответ для восстановления при возврате
|
||||
saveFinalAnswer(taskId, result)
|
||||
// Очищаем черновик, так как теперь есть финальный ответ
|
||||
clearDraft(taskId)
|
||||
pollingManager.stop()
|
||||
return false
|
||||
|
||||
57
src/pages/chains/ChainsPage.tsx
Normal file
57
src/pages/chains/ChainsPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { useChallenge } from '../../context/ChallengeContext'
|
||||
import { Header } from '../../components/Header'
|
||||
import { ChainSelector } from '../../components/ChainSelector'
|
||||
import { storage } from '../../utils/storage'
|
||||
import type { ChallengeChain } from '../../__data__/types'
|
||||
|
||||
export const ChainsPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { nickname } = useChallenge()
|
||||
|
||||
// Проверяем авторизацию
|
||||
useEffect(() => {
|
||||
const workplaceNumber = storage.getWorkplaceNumber()
|
||||
if (!workplaceNumber) {
|
||||
navigate(URLs.workplace, { replace: true })
|
||||
return
|
||||
}
|
||||
if (!nickname) {
|
||||
navigate(URLs.login, { replace: true })
|
||||
}
|
||||
}, [navigate, nickname])
|
||||
|
||||
const handleSelectChain = (chain: ChallengeChain) => {
|
||||
storage.setSelectedChainId(chain.id)
|
||||
|
||||
if (chain.tasks.length > 0) {
|
||||
// Получаем самый дальний достигнутый индекс
|
||||
const furthestIndex = storage.getFurthestTaskIndex(chain.id)
|
||||
|
||||
// Если нет прогресса, инициализируем с первого задания
|
||||
const targetIndex = furthestIndex >= 0 ? furthestIndex : 0
|
||||
const targetTask = chain.tasks[targetIndex] || chain.tasks[0]
|
||||
|
||||
storage.setSelectedTaskId(targetTask.id)
|
||||
// Убеждаемся, что прогресс установлен
|
||||
storage.setFurthestTaskIndex(chain.id, targetIndex)
|
||||
|
||||
navigate(URLs.task(chain.id, targetTask.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (!nickname) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<ChainSelector onSelectChain={handleSelectChain} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
4
src/pages/chains/index.ts
Normal file
4
src/pages/chains/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ChainsPage } from './ChainsPage'
|
||||
|
||||
export default ChainsPage
|
||||
|
||||
99
src/pages/completed/CompletedPage.tsx
Normal file
99
src/pages/completed/CompletedPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { useChallenge } from '../../context/ChallengeContext'
|
||||
import { Header } from '../../components/Header'
|
||||
import { storage } from '../../utils/storage'
|
||||
|
||||
export const CompletedPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { chainId } = useParams<{ chainId: string }>()
|
||||
const { nickname, chains } = useChallenge()
|
||||
|
||||
// Проверяем авторизацию
|
||||
useEffect(() => {
|
||||
const workplaceNumber = storage.getWorkplaceNumber()
|
||||
if (!workplaceNumber) {
|
||||
navigate(URLs.workplace, { replace: true })
|
||||
return
|
||||
}
|
||||
if (!nickname) {
|
||||
navigate(URLs.login, { replace: true })
|
||||
}
|
||||
}, [navigate, nickname])
|
||||
|
||||
const chain = useMemo(() => {
|
||||
return chains.find(c => c.id === chainId) || null
|
||||
}, [chains, chainId])
|
||||
|
||||
const handleContinue = () => {
|
||||
navigate(URLs.chains)
|
||||
}
|
||||
|
||||
if (!nickname) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||
<Box
|
||||
bg="white"
|
||||
borderWidth="2px"
|
||||
borderRadius="xl"
|
||||
borderColor="green.300"
|
||||
p={10}
|
||||
maxW="600px"
|
||||
w="full"
|
||||
textAlign="center"
|
||||
shadow="lg"
|
||||
>
|
||||
<VStack gap={6}>
|
||||
<Text fontSize="6xl">🎉</Text>
|
||||
<Heading size="xl" color="green.600">
|
||||
Поздравляем!
|
||||
</Heading>
|
||||
<Text fontSize="lg" color="gray.700">
|
||||
Вы успешно выполнили все задания
|
||||
</Text>
|
||||
{chain && (
|
||||
<Box
|
||||
bg="green.50"
|
||||
borderRadius="lg"
|
||||
px={6}
|
||||
py={3}
|
||||
borderWidth="1px"
|
||||
borderColor="green.200"
|
||||
>
|
||||
<Text fontSize="xl" fontWeight="bold" color="green.700">
|
||||
{chain.name}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text fontSize="md" color="gray.600">
|
||||
Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий.
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
size="lg"
|
||||
onClick={handleContinue}
|
||||
mt={4}
|
||||
>
|
||||
Продолжить
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
4
src/pages/completed/index.ts
Normal file
4
src/pages/completed/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { CompletedPage } from './CompletedPage'
|
||||
|
||||
export default CompletedPage
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { lazy } from 'react'
|
||||
|
||||
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
||||
export const WorkplacePage = lazy(() => import(/* webpackChunkName: 'workplace' */'./workplace'))
|
||||
export const LoginPage = lazy(() => import(/* webpackChunkName: 'login' */'./login'))
|
||||
export const ChainsPage = lazy(() => import(/* webpackChunkName: 'chains' */'./chains'))
|
||||
export const TaskPage = lazy(() => import(/* webpackChunkName: 'task' */'./task'))
|
||||
export const CompletedPage = lazy(() => import(/* webpackChunkName: 'completed' */'./completed'))
|
||||
141
src/pages/login/LoginPage.tsx
Normal file
141
src/pages/login/LoginPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { useChallenge } from '../../context/ChallengeContext'
|
||||
import { storage } from '../../utils/storage'
|
||||
|
||||
export const LoginPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { login, isAuthLoading, nickname } = useChallenge()
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const workplaceNumber = storage.getWorkplaceNumber()
|
||||
|
||||
// Если нет номера рабочего места, возвращаемся
|
||||
useEffect(() => {
|
||||
if (!workplaceNumber) {
|
||||
navigate(URLs.workplace, { replace: true })
|
||||
return
|
||||
}
|
||||
// Если уже авторизован, переходим к цепочкам
|
||||
if (nickname) {
|
||||
navigate(URLs.chains, { replace: true })
|
||||
}
|
||||
}, [navigate, workplaceNumber, nickname])
|
||||
|
||||
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, workplaceNumber || '')
|
||||
navigate(URLs.chains)
|
||||
} catch (err) {
|
||||
setError('Произошла ошибка при входе. Попробуйте снова.')
|
||||
console.error('Login error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeWorkplace = () => {
|
||||
storage.removeWorkplaceNumber()
|
||||
navigate(URLs.workplace)
|
||||
}
|
||||
|
||||
if (!workplaceNumber) {
|
||||
return null
|
||||
}
|
||||
|
||||
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" fontWeight="medium">
|
||||
Рабочее место: №{workplaceNumber}
|
||||
</Text>
|
||||
<Text color="gray.600" mt={3}>
|
||||
Введите ваше ФИО для начала работы
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||
<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
|
||||
// @ts-expect-error Chakra UI v2 uses isInvalid
|
||||
isInvalid={!!error}
|
||||
/>
|
||||
{error && (
|
||||
<Text color="red.500" fontSize="sm" mt={2}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
|
||||
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
|
||||
Войти
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
|
||||
<Box textAlign="center" mt={120}>
|
||||
<Text
|
||||
as="button"
|
||||
type="button"
|
||||
color="gray.500"
|
||||
fontSize="sm"
|
||||
cursor="pointer"
|
||||
_hover={{ color: 'teal.600', textDecoration: 'underline' }}
|
||||
onClick={handleChangeWorkplace}
|
||||
>
|
||||
Изменить рабочее место
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
4
src/pages/login/index.ts
Normal file
4
src/pages/login/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { LoginPage } from './LoginPage'
|
||||
|
||||
export default LoginPage
|
||||
|
||||
@@ -1,160 +1,36 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { Alert } from '@chakra-ui/react/alert'
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
|
||||
import { useChallenge } from '../../context/ChallengeContext'
|
||||
import { TaskWorkspace } from '../../components/personal'
|
||||
import { Header } from '../../components/Header'
|
||||
import { LoginForm } from '../../components/LoginForm'
|
||||
import { ChainSelector } from '../../components/ChainSelector'
|
||||
|
||||
const SELECTED_CHAIN_KEY = 'challengeSelectedChainId'
|
||||
const SELECTED_TASK_KEY = 'challengeSelectedTaskId'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { storage } from '../../utils/storage'
|
||||
|
||||
/**
|
||||
* MainPage теперь просто редиректит на нужную страницу
|
||||
* Вся логика навигации находится в отдельных страницах:
|
||||
* - /workplace - ввод номера рабочего места
|
||||
* - /login - ввод ФИО
|
||||
* - /chains - выбор цепочки
|
||||
* - /chain/:chainId/task/:taskId - задание
|
||||
* - /completed/:chainId - завершение цепочки
|
||||
*/
|
||||
export const MainPage = () => {
|
||||
const { nickname, eventEmitter, chains } = useChallenge()
|
||||
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
|
||||
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
|
||||
const [isOffline, setIsOffline] = useState(() =>
|
||||
typeof navigator !== 'undefined' ? !navigator.onLine : false,
|
||||
)
|
||||
const [notification, setNotification] = useState<{ status: 'success' | 'warning'; title: string; description?: string } | null>(null)
|
||||
const notificationTimeoutRef = useRef<number | null>(null)
|
||||
const hasRestoredState = useRef(false)
|
||||
const workplaceNumber = storage.getWorkplaceNumber()
|
||||
const nickname = storage.getNickname()
|
||||
const chainId = storage.getSelectedChainId()
|
||||
const taskId = storage.getSelectedTaskId()
|
||||
|
||||
// Восстановление состояния при загрузке
|
||||
useEffect(() => {
|
||||
if (hasRestoredState.current || !chains.length || !nickname) return
|
||||
|
||||
const savedChainId = localStorage.getItem(SELECTED_CHAIN_KEY)
|
||||
const savedTaskId = localStorage.getItem(SELECTED_TASK_KEY)
|
||||
|
||||
if (savedChainId) {
|
||||
const chain = chains.find(c => c.id === savedChainId)
|
||||
if (chain) {
|
||||
setSelectedChain(chain)
|
||||
|
||||
if (savedTaskId) {
|
||||
const task = chain.tasks.find(t => t.id === savedTaskId)
|
||||
setSelectedTask(task || chain.tasks[0])
|
||||
} else {
|
||||
setSelectedTask(chain.tasks[0])
|
||||
// Если есть сохранённое задание - туда
|
||||
if (nickname && chainId && taskId) {
|
||||
return <Navigate to={URLs.task(chainId, taskId)} replace />
|
||||
}
|
||||
// Если авторизован - к цепочкам
|
||||
if (nickname) {
|
||||
return <Navigate to={URLs.chains} replace />
|
||||
}
|
||||
// Если есть номер места - к логину
|
||||
if (workplaceNumber) {
|
||||
return <Navigate to={URLs.login} replace />
|
||||
}
|
||||
|
||||
hasRestoredState.current = true
|
||||
}, [chains, nickname])
|
||||
|
||||
const handleSelectChain = (chain: ChallengeChain) => {
|
||||
setSelectedChain(chain)
|
||||
setSelectedTask(chain.tasks[0])
|
||||
localStorage.setItem(SELECTED_CHAIN_KEY, chain.id)
|
||||
localStorage.setItem(SELECTED_TASK_KEY, chain.tasks[0].id)
|
||||
}
|
||||
|
||||
const handleTaskComplete = () => {
|
||||
if (!selectedChain) return
|
||||
const currentIndex = selectedChain.tasks.findIndex((item) => item.id === selectedTask?.id)
|
||||
const nextTask = currentIndex >= 0 ? selectedChain.tasks[currentIndex + 1] : null
|
||||
|
||||
if (nextTask) {
|
||||
setSelectedTask(nextTask)
|
||||
localStorage.setItem(SELECTED_TASK_KEY, nextTask.id)
|
||||
} else {
|
||||
// Цепочка завершена, возвращаемся к выбору
|
||||
setSelectedChain(null)
|
||||
setSelectedTask(null)
|
||||
localStorage.removeItem(SELECTED_CHAIN_KEY)
|
||||
localStorage.removeItem(SELECTED_TASK_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
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 ? 'Задание принято' : 'Задание требует доработки'
|
||||
const description = submission ? `Попытка №${submission.attemptNumber}` : undefined
|
||||
|
||||
if (notificationTimeoutRef.current) {
|
||||
window.clearTimeout(notificationTimeoutRef.current)
|
||||
}
|
||||
|
||||
setNotification({ status: accepted ? 'success' : 'warning', title, description })
|
||||
notificationTimeoutRef.current = window.setTimeout(() => setNotification(null), 4000)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
if (notificationTimeoutRef.current) {
|
||||
window.clearTimeout(notificationTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [eventEmitter])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false)
|
||||
const handleOffline = () => setIsOffline(true)
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Если пользователь не авторизован, показываем форму входа
|
||||
if (!nickname) {
|
||||
return <LoginForm />
|
||||
}
|
||||
|
||||
// Если цепочка не выбрана, показываем селектор цепочек
|
||||
if (!selectedChain) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<ChainSelector onSelectChain={handleSelectChain} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const taskProgress = `Задание ${selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1} из ${selectedChain.tasks.length}`
|
||||
|
||||
// Показываем выбранную цепочку и задания
|
||||
return (
|
||||
<>
|
||||
<Header chainName={selectedChain.name} taskProgress={taskProgress} />
|
||||
<Box bg="gray.50" minH="100vh" py={4} px={{ base: 4, md: 8 }}>
|
||||
<Box maxW="1200px" mx="auto">
|
||||
{notification && (
|
||||
<Alert.Root status={notification.status} borderRadius="md" mb={4}>
|
||||
<Alert.Indicator />
|
||||
<Box ml={3}>
|
||||
<Text fontWeight="semibold">{notification.title}</Text>
|
||||
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
|
||||
</Box>
|
||||
</Alert.Root>
|
||||
)}
|
||||
|
||||
{isOffline && (
|
||||
<Alert.Root status="warning" borderRadius="md" mb={4}>
|
||||
<Alert.Indicator />
|
||||
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
|
||||
</Alert.Root>
|
||||
)}
|
||||
|
||||
{selectedTask && (
|
||||
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
// Иначе - к вводу места
|
||||
return <Navigate to={URLs.workplace} replace />
|
||||
}
|
||||
|
||||
203
src/pages/task/TaskPage.tsx
Normal file
203
src/pages/task/TaskPage.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
HStack,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { useChallenge } from '../../context/ChallengeContext'
|
||||
import { TaskWorkspace } from '../../components/personal'
|
||||
import { Header } from '../../components/Header'
|
||||
import { storage } from '../../utils/storage'
|
||||
|
||||
export const TaskPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { chainId, taskId } = useParams<{ chainId: string; taskId: string }>()
|
||||
const { nickname, chains } = useChallenge()
|
||||
|
||||
// Проверяем авторизацию
|
||||
useEffect(() => {
|
||||
const workplaceNumber = storage.getWorkplaceNumber()
|
||||
if (!workplaceNumber) {
|
||||
navigate(URLs.workplace, { replace: true })
|
||||
return
|
||||
}
|
||||
if (!nickname) {
|
||||
navigate(URLs.login, { replace: true })
|
||||
}
|
||||
}, [navigate, nickname])
|
||||
|
||||
// Находим цепочку и задание
|
||||
const chain = useMemo(() => {
|
||||
return chains.find(c => c.id === chainId) || null
|
||||
}, [chains, chainId])
|
||||
|
||||
const task = useMemo(() => {
|
||||
return chain?.tasks.find(t => t.id === taskId) || null
|
||||
}, [chain, taskId])
|
||||
|
||||
const currentTaskIndex = useMemo(() => {
|
||||
if (!chain || !taskId) return -1
|
||||
return chain.tasks.findIndex(t => t.id === taskId)
|
||||
}, [chain, taskId])
|
||||
|
||||
// Получаем самый дальний достигнутый индекс задания (используем state для реактивности)
|
||||
const [furthestTaskIndex, setFurthestTaskIndex] = useState(() => {
|
||||
if (!chainId) return 0
|
||||
return storage.getFurthestTaskIndex(chainId)
|
||||
})
|
||||
|
||||
// Обновляем furthestTaskIndex при изменении chainId или currentTaskIndex
|
||||
useEffect(() => {
|
||||
if (!chainId) return
|
||||
const currentFurthest = storage.getFurthestTaskIndex(chainId)
|
||||
setFurthestTaskIndex(currentFurthest)
|
||||
}, [chainId, currentTaskIndex])
|
||||
|
||||
// Сохраняем текущее состояние в storage и обновляем прогресс
|
||||
useEffect(() => {
|
||||
if (chainId && taskId && currentTaskIndex >= 0) {
|
||||
storage.setSelectedChainId(chainId)
|
||||
storage.setSelectedTaskId(taskId)
|
||||
// Обновляем прогресс, если текущее задание дальше предыдущего
|
||||
const newFurthest = Math.max(furthestTaskIndex, currentTaskIndex)
|
||||
if (newFurthest > furthestTaskIndex) {
|
||||
storage.setFurthestTaskIndex(chainId, newFurthest)
|
||||
setFurthestTaskIndex(newFurthest)
|
||||
}
|
||||
}
|
||||
}, [chainId, taskId, currentTaskIndex, furthestTaskIndex])
|
||||
|
||||
// Проверка доступности задания
|
||||
const isTaskAccessible = (taskIndex: number): boolean => {
|
||||
return taskIndex <= furthestTaskIndex
|
||||
}
|
||||
|
||||
const handleTaskComplete = () => {
|
||||
if (!chain || currentTaskIndex === -1) return
|
||||
|
||||
const nextTaskIndex = currentTaskIndex + 1
|
||||
const nextTask = chain.tasks[nextTaskIndex]
|
||||
|
||||
if (nextTask) {
|
||||
// Обновляем прогресс перед переходом
|
||||
storage.setFurthestTaskIndex(chain.id, nextTaskIndex)
|
||||
setFurthestTaskIndex(nextTaskIndex) // Обновляем state сразу
|
||||
navigate(URLs.task(chain.id, nextTask.id))
|
||||
} else {
|
||||
// Цепочка завершена
|
||||
storage.clearSessionData()
|
||||
navigate(URLs.completed(chain.id))
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigateToTask = (newTaskId: string) => {
|
||||
if (!chain) return
|
||||
|
||||
const newTaskIndex = chain.tasks.findIndex(t => t.id === newTaskId)
|
||||
if (newTaskIndex === -1) return
|
||||
|
||||
// Проверяем доступность
|
||||
if (!isTaskAccessible(newTaskIndex)) {
|
||||
return // Не переходим к заблокированному заданию
|
||||
}
|
||||
|
||||
// Обновляем прогресс при переходе
|
||||
const newFurthest = Math.max(furthestTaskIndex, newTaskIndex)
|
||||
if (newFurthest > furthestTaskIndex) {
|
||||
storage.setFurthestTaskIndex(chain.id, newFurthest)
|
||||
setFurthestTaskIndex(newFurthest)
|
||||
}
|
||||
navigate(URLs.task(chain.id, newTaskId))
|
||||
}
|
||||
|
||||
// Проверяем доступность текущего задания при загрузке
|
||||
useEffect(() => {
|
||||
if (chain && currentTaskIndex >= 0 && !isTaskAccessible(currentTaskIndex)) {
|
||||
// Если пытаемся открыть недоступное задание, перенаправляем на последнее доступное
|
||||
const lastAccessibleIndex = furthestTaskIndex
|
||||
if (lastAccessibleIndex >= 0 && chain.tasks[lastAccessibleIndex]) {
|
||||
navigate(URLs.task(chain.id, chain.tasks[lastAccessibleIndex].id), { replace: true })
|
||||
} else if (chain.tasks[0]) {
|
||||
// Если нет прогресса, идём к первому заданию
|
||||
navigate(URLs.task(chain.id, chain.tasks[0].id), { replace: true })
|
||||
}
|
||||
}
|
||||
}, [chain, currentTaskIndex, furthestTaskIndex, navigate])
|
||||
|
||||
const handleBackToChains = () => {
|
||||
storage.clearSessionData()
|
||||
navigate(URLs.chains)
|
||||
}
|
||||
|
||||
if (!nickname || !chain || !task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const taskProgress = `Задание ${currentTaskIndex + 1}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header chainName={chain.name} taskProgress={taskProgress} />
|
||||
<Box bg="gray.50" minH="100vh" py={4} px={{ base: 4, md: 8 }}>
|
||||
<Box maxW="1200px" mx="auto">
|
||||
{/* Навигация по заданиям */}
|
||||
<Box
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
mb={4}
|
||||
shadow="sm"
|
||||
>
|
||||
<Flex justify="space-between" align="center" flexWrap="wrap" gap={3}>
|
||||
<HStack gap={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.600" mr={2}>
|
||||
Задания:
|
||||
</Text>
|
||||
{chain.tasks.map((t, index) => {
|
||||
const isAccessible = isTaskAccessible(index)
|
||||
const isCurrent = t.id === taskId
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={t.id}
|
||||
size="sm"
|
||||
variant={isCurrent ? 'solid' : isAccessible ? 'outline' : 'ghost'}
|
||||
colorScheme={isCurrent ? 'teal' : 'gray'}
|
||||
// @ts-expect-error Chakra UI v2 uses isDisabled
|
||||
isDisabled={!isAccessible}
|
||||
onClick={() => isAccessible && handleNavigateToTask(t.id)}
|
||||
minW="40px"
|
||||
opacity={isAccessible ? 1 : 0.5}
|
||||
cursor={isAccessible ? 'pointer' : 'not-allowed'}
|
||||
>
|
||||
{isAccessible ? index + 1 : `🔒${index + 1}`}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="gray"
|
||||
onClick={handleBackToChains}
|
||||
>
|
||||
← К выбору цепочки
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<TaskWorkspace task={task} onTaskComplete={handleTaskComplete} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
4
src/pages/task/index.ts
Normal file
4
src/pages/task/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { TaskPage } from './TaskPage'
|
||||
|
||||
export default TaskPage
|
||||
|
||||
145
src/pages/workplace/WorkplacePage.tsx
Normal file
145
src/pages/workplace/WorkplacePage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Heading,
|
||||
Input,
|
||||
Link,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { storage } from '../../utils/storage'
|
||||
|
||||
// Список полезных ссылок
|
||||
const USEFUL_LINKS = [
|
||||
{ url: 'https://ya.ru', label: 'Яндекс' },
|
||||
{ url: 'https://giga.chat', label: 'GigaChat' },
|
||||
]
|
||||
|
||||
export const WorkplacePage = () => {
|
||||
const navigate = useNavigate()
|
||||
const [workplaceNumber, setWorkplaceNumber] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Если номер уже есть, переходим дальше
|
||||
useEffect(() => {
|
||||
const saved = storage.getWorkplaceNumber()
|
||||
if (saved) {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
const nickname = storage.getNickname()
|
||||
if (nickname) {
|
||||
navigate(URLs.chains, { replace: true })
|
||||
} else {
|
||||
navigate(URLs.login, { replace: true })
|
||||
}
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const trimmed = workplaceNumber.trim()
|
||||
if (!trimmed) {
|
||||
setError('Пожалуйста, введите номер рабочего места')
|
||||
return
|
||||
}
|
||||
|
||||
storage.setWorkplaceNumber(trimmed)
|
||||
navigate(URLs.login)
|
||||
}
|
||||
|
||||
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={handleSubmit}>
|
||||
{/* @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>
|
||||
|
||||
{/* Полезные ссылки */}
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
borderColor="gray.200"
|
||||
p={4}
|
||||
bg="gray.50"
|
||||
>
|
||||
<Text fontWeight="medium" mb={3} fontSize="sm">
|
||||
Полезные ссылки:
|
||||
</Text>
|
||||
|
||||
{/* @ts-expect-error Chakra UI v2 uses spacing */}
|
||||
<VStack spacing={2} align="stretch">
|
||||
{USEFUL_LINKS.map(link => (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
color="teal.600"
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
_hover={{ color: 'teal.800', textDecoration: 'underline' }}
|
||||
>
|
||||
🔗 {link.label}
|
||||
</Link>
|
||||
))}
|
||||
</VStack>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
4
src/pages/workplace/index.ts
Normal file
4
src/pages/workplace/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { WorkplacePage } from './WorkplacePage'
|
||||
|
||||
export default WorkplacePage
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const STORAGE_PREFIX = 'challenge_draft_'
|
||||
const FINAL_ANSWER_PREFIX = 'challenge_final_answer_'
|
||||
|
||||
const isBrowser = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
|
||||
@@ -17,6 +18,24 @@ export function clearDraft(taskId: string) {
|
||||
window.localStorage.removeItem(`${STORAGE_PREFIX}${taskId}`)
|
||||
}
|
||||
|
||||
// Сохранение финального ответа (после успешной отправки)
|
||||
export function saveFinalAnswer(taskId: string, result: string) {
|
||||
if (!isBrowser()) return
|
||||
window.localStorage.setItem(`${FINAL_ANSWER_PREFIX}${taskId}`, result)
|
||||
}
|
||||
|
||||
// Загрузка финального ответа
|
||||
export function loadFinalAnswer(taskId: string): string | null {
|
||||
if (!isBrowser()) return null
|
||||
return window.localStorage.getItem(`${FINAL_ANSWER_PREFIX}${taskId}`)
|
||||
}
|
||||
|
||||
// Очистка финального ответа
|
||||
export function clearFinalAnswer(taskId: string) {
|
||||
if (!isBrowser()) return
|
||||
window.localStorage.removeItem(`${FINAL_ANSWER_PREFIX}${taskId}`)
|
||||
}
|
||||
|
||||
export function listDrafts() {
|
||||
if (!isBrowser()) return [] as string[]
|
||||
|
||||
@@ -30,3 +49,16 @@ export function listDrafts() {
|
||||
return keys
|
||||
}
|
||||
|
||||
// Очистка всех финальных ответов (при выходе)
|
||||
export function clearAllFinalAnswers() {
|
||||
if (!isBrowser()) return
|
||||
const keysToRemove: string[] = []
|
||||
for (let i = 0; i < window.localStorage.length; i += 1) {
|
||||
const key = window.localStorage.key(i)
|
||||
if (key?.startsWith(FINAL_ANSWER_PREFIX)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => window.localStorage.removeItem(key))
|
||||
}
|
||||
|
||||
|
||||
165
src/utils/storage.ts
Normal file
165
src/utils/storage.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Централизованная работа с localStorage
|
||||
* Все ключи и операции в одном месте
|
||||
*/
|
||||
|
||||
import { clearAllFinalAnswers, listDrafts, clearDraft } from './drafts'
|
||||
|
||||
const isBrowser = () => typeof window !== 'undefined'
|
||||
|
||||
// Ключи localStorage
|
||||
export const STORAGE_KEYS = {
|
||||
USER_ID: 'challengeUserId',
|
||||
NICKNAME: 'challengeNickname',
|
||||
WORKPLACE_NUMBER: 'challengeWorkplaceNumber',
|
||||
SELECTED_CHAIN_ID: 'challengeSelectedChainId',
|
||||
SELECTED_TASK_ID: 'challengeSelectedTaskId',
|
||||
} as const
|
||||
|
||||
// Вспомогательная функция для ключа прогресса цепочки
|
||||
const getFurthestTaskKey = (chainId: string) => `challengeFurthestTask_${chainId}`
|
||||
|
||||
// Получение значений
|
||||
export const storage = {
|
||||
getUserId: (): string | null => {
|
||||
if (!isBrowser()) return null
|
||||
return localStorage.getItem(STORAGE_KEYS.USER_ID)
|
||||
},
|
||||
|
||||
getNickname: (): string | null => {
|
||||
if (!isBrowser()) return null
|
||||
return localStorage.getItem(STORAGE_KEYS.NICKNAME)
|
||||
},
|
||||
|
||||
getWorkplaceNumber: (): string | null => {
|
||||
if (!isBrowser()) return null
|
||||
return localStorage.getItem(STORAGE_KEYS.WORKPLACE_NUMBER)
|
||||
},
|
||||
|
||||
getSelectedChainId: (): string | null => {
|
||||
if (!isBrowser()) return null
|
||||
return localStorage.getItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
|
||||
},
|
||||
|
||||
getSelectedTaskId: (): string | null => {
|
||||
if (!isBrowser()) return null
|
||||
return localStorage.getItem(STORAGE_KEYS.SELECTED_TASK_ID)
|
||||
},
|
||||
|
||||
// Установка значений
|
||||
setUserId: (value: string): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.setItem(STORAGE_KEYS.USER_ID, value)
|
||||
},
|
||||
|
||||
setNickname: (value: string): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.setItem(STORAGE_KEYS.NICKNAME, value)
|
||||
},
|
||||
|
||||
setWorkplaceNumber: (value: string): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.setItem(STORAGE_KEYS.WORKPLACE_NUMBER, value)
|
||||
},
|
||||
|
||||
setSelectedChainId: (value: string): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.setItem(STORAGE_KEYS.SELECTED_CHAIN_ID, value)
|
||||
},
|
||||
|
||||
setSelectedTaskId: (value: string): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.setItem(STORAGE_KEYS.SELECTED_TASK_ID, value)
|
||||
},
|
||||
|
||||
// Удаление значений
|
||||
removeUserId: (): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_ID)
|
||||
},
|
||||
|
||||
removeNickname: (): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.removeItem(STORAGE_KEYS.NICKNAME)
|
||||
},
|
||||
|
||||
removeWorkplaceNumber: (): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.removeItem(STORAGE_KEYS.WORKPLACE_NUMBER)
|
||||
},
|
||||
|
||||
removeSelectedChainId: (): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
|
||||
},
|
||||
|
||||
removeSelectedTaskId: (): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
|
||||
},
|
||||
|
||||
// Полная очистка при выходе (кроме номера рабочего места)
|
||||
clearAll: (): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_ID)
|
||||
localStorage.removeItem(STORAGE_KEYS.NICKNAME)
|
||||
// Номер рабочего места НЕ удаляем
|
||||
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
|
||||
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
|
||||
|
||||
// Очищаем все прогрессы по цепочкам
|
||||
storage.clearAllChainProgress()
|
||||
|
||||
// Очищаем все финальные ответы
|
||||
clearAllFinalAnswers()
|
||||
|
||||
// Очищаем все черновики
|
||||
const drafts = listDrafts()
|
||||
drafts.forEach(taskId => clearDraft(taskId))
|
||||
},
|
||||
|
||||
// Очистка всех прогрессов по цепочкам
|
||||
clearAllChainProgress: (): void => {
|
||||
if (!isBrowser()) return
|
||||
// Перебираем все ключи localStorage и удаляем те, что начинаются с challengeFurthestTask_
|
||||
const keysToRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key && key.startsWith('challengeFurthestTask_')) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key))
|
||||
},
|
||||
|
||||
// Очистка данных сессии (цепочка, задание) без выхода
|
||||
clearSessionData: (): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
|
||||
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
|
||||
},
|
||||
|
||||
// Получение самого дальнего достигнутого индекса задания в цепочке
|
||||
getFurthestTaskIndex: (chainId: string): number => {
|
||||
if (!isBrowser()) return 0
|
||||
const value = localStorage.getItem(getFurthestTaskKey(chainId))
|
||||
return value ? parseInt(value, 10) : 0
|
||||
},
|
||||
|
||||
// Установка самого дальнего достигнутого индекса задания
|
||||
setFurthestTaskIndex: (chainId: string, index: number): void => {
|
||||
if (!isBrowser()) return
|
||||
const current = storage.getFurthestTaskIndex(chainId)
|
||||
// Обновляем только если новый индекс больше текущего
|
||||
if (index > current) {
|
||||
localStorage.setItem(getFurthestTaskKey(chainId), index.toString())
|
||||
}
|
||||
},
|
||||
|
||||
// Очистка прогресса цепочки
|
||||
clearChainProgress: (chainId: string): void => {
|
||||
if (!isBrowser()) return
|
||||
localStorage.removeItem(getFurthestTaskKey(chainId))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -23,6 +23,38 @@
|
||||
"description": "# React компонент\n\nСоздайте компонент `StatCard` с пропсами `title` и `value`.",
|
||||
"createdAt": "2024-09-05T11:30:00.000Z",
|
||||
"updatedAt": "2024-10-01T09:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "task-css-layout",
|
||||
"_id": "task-css-layout",
|
||||
"title": "CSS-верстка",
|
||||
"description": "# CSS-верстка\n\nСверстайте карточку с заголовком, описанием и кнопкой. Используйте Flexbox и тени.",
|
||||
"createdAt": "2024-09-10T14:00:00.000Z",
|
||||
"updatedAt": "2024-10-05T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "task-js-dom",
|
||||
"_id": "task-js-dom",
|
||||
"title": "DOM-манипуляции",
|
||||
"description": "# DOM-манипуляции\n\nНапишите скрипт, который добавляет элементы списка по клику на кнопку и очищает их по второй кнопке.",
|
||||
"createdAt": "2024-09-15T10:20:00.000Z",
|
||||
"updatedAt": "2024-10-07T12:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "task-react-state",
|
||||
"_id": "task-react-state",
|
||||
"title": "Состояние React",
|
||||
"description": "# Состояние React\n\nСоздайте компонент, который показывает счётчик кликов и два кнопки — увеличить/сбросить. Используйте хуки.",
|
||||
"createdAt": "2024-09-20T09:15:00.000Z",
|
||||
"updatedAt": "2024-10-08T08:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "task-api-fetch",
|
||||
"_id": "task-api-fetch",
|
||||
"title": "Работа с API",
|
||||
"description": "# Работа с API\n\nСделайте запрос к фиктивному API и отобразите список пользователей с именами и email.",
|
||||
"createdAt": "2024-09-25T13:40:00.000Z",
|
||||
"updatedAt": "2024-10-09T10:10:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const router = require('express').Router()
|
||||
|
||||
const timer = (time = 300) => (req, res, next) => setTimeout(next, time)
|
||||
const timer = (time = 100) => (req, res, next) => setTimeout(next, time)
|
||||
|
||||
const dataDir = path.join(__dirname, 'data')
|
||||
|
||||
@@ -57,17 +57,96 @@ router.get('/challenge/task/:id', (req, res) => {
|
||||
|
||||
router.post('/challenge/submit', (req, res) => {
|
||||
const response = readJson('submit.json')
|
||||
const queueId = response.body?.queueId
|
||||
|
||||
if (queueId) {
|
||||
queueBehaviors[queueId] = queueBehaviors[queueId] ?? { nextFailure: false, attemptNumber: 0 }
|
||||
queueStates[queueId] = {
|
||||
position: 3,
|
||||
initialPosition: 3,
|
||||
pollCount: 0,
|
||||
startTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
res.json(response)
|
||||
})
|
||||
|
||||
router.get('/challenge/check-status/:queueId', (req, res) => {
|
||||
const data = readJson('queue-status.json')
|
||||
const statuses = data.body || data
|
||||
const status = statuses[req.params.queueId]
|
||||
// Храним состояние очереди для каждого queueId
|
||||
const queueStates = {}
|
||||
const queueBehaviors = {}
|
||||
|
||||
if (!status) {
|
||||
return sendNotFound(res, `Статус очереди ${req.params.queueId} не найден`)
|
||||
router.get('/challenge/check-status/:queueId', (req, res) => {
|
||||
const queueId = req.params.queueId
|
||||
|
||||
// Инициализируем состояние очереди, если его нет
|
||||
if (!queueStates[queueId]) {
|
||||
queueStates[queueId] = {
|
||||
position: 3,
|
||||
initialPosition: 3,
|
||||
pollCount: 0,
|
||||
startTime: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const state = queueStates[queueId]
|
||||
state.pollCount++
|
||||
|
||||
// Симулируем движение в очереди
|
||||
// Каждый запрос уменьшаем позицию (быстрее)
|
||||
if (state.pollCount >= 1 && state.position > 0) {
|
||||
state.position--
|
||||
state.pollCount = 0
|
||||
}
|
||||
|
||||
// Если позиция 0, переходим к проверке
|
||||
if (state.position === 0 && state.pollCount >= 1) {
|
||||
const behavior = queueBehaviors[queueId] ?? { nextFailure: false, attemptNumber: 0 }
|
||||
const attemptNumber = behavior.attemptNumber + 1
|
||||
behavior.attemptNumber = attemptNumber
|
||||
const shouldFail = behavior.nextFailure
|
||||
behavior.nextFailure = !shouldFail
|
||||
|
||||
const baseSubmission = {
|
||||
_id: `submission-${queueId}-${attemptNumber}`,
|
||||
id: `submission-${queueId}-${attemptNumber}`,
|
||||
user: 'user-frontend-001',
|
||||
task: 'task-html-intro',
|
||||
result: '<html><head></head><body><h1>Hello</h1></body></html>',
|
||||
queueId,
|
||||
submittedAt: new Date(state.startTime).toISOString(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
attemptNumber,
|
||||
}
|
||||
|
||||
const submission = shouldFail
|
||||
? {
|
||||
...baseSubmission,
|
||||
status: 'needs_revision',
|
||||
feedback: 'Добавьте описание внутри <section> и поясните, зачем нужен заголовок.',
|
||||
}
|
||||
: {
|
||||
...baseSubmission,
|
||||
status: 'accepted',
|
||||
feedback: 'Отличная работа! Теперь можно двигаться дальше.',
|
||||
}
|
||||
|
||||
delete queueStates[queueId]
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
body: {
|
||||
status: 'completed',
|
||||
position: 0,
|
||||
submission,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Возвращаем текущее состояние очереди с начальной позицией для расчёта прогресса
|
||||
const status = state.position > 0
|
||||
? { status: 'waiting', position: state.position, initialPosition: state.initialPosition }
|
||||
: { status: 'in_progress', position: 0, initialPosition: state.initialPosition }
|
||||
|
||||
return res.json({ success: true, body: status })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user