4 Commits

11 changed files with 718 additions and 108 deletions
+202
View File
@@ -0,0 +1,202 @@
# Добавление поля learningMaterial в задачу челленджа
## Описание изменений
В модель задачи челленджа (`ChallengeTask`) добавлено новое необязательное текстовое поле `learningMaterial` для хранения дополнительной обучающей информации в формате Markdown.
## Структура данных
### Модель ChallengeTask
```typescript
{
title: string, // Заголовок задания (обязательное)
description: string, // Основное описание в Markdown (обязательное, видно студентам)
learningMaterial: string, // Дополнительный учебный материал в Markdown (необязательное, видно студентам)
hiddenInstructions: string, // Скрытые инструкции для LLM (необязательное, только для преподавателей)
createdAt: Date, // Дата создания
updatedAt: Date, // Дата последнего обновления
creator: Object // Данные создателя из Keycloak
}
```
## Изменения в API
### 1. Создание задания (POST /challenge/task)
**Добавлено поле в тело запроса:**
```json
{
"title": "Название задания",
"description": "Основное описание в Markdown",
"learningMaterial": "Дополнительный учебный материал в Markdown",
"hiddenInstructions": "Скрытые инструкции для преподавателей"
}
```
**Пример запроса:**
```bash
POST /challenge/task
Content-Type: application/json
{
"title": "Реализация алгоритма сортировки",
"description": "Напишите функцию сортировки массива методом пузырька",
"learningMaterial": "## Теория\n\nМетод пузырьковой сортировки работает путем...\n\n## Полезные ссылки\n- [Википедия](https://ru.wikipedia.org/wiki/Сортировка_пузырьком)\n- [Видео объяснение](https://example.com/video)",
"hiddenInstructions": "Оценить эффективность алгоритма и стиль кода"
}
```
### 2. Обновление задания (PUT /challenge/task/:taskId)
**Добавлено поле в тело запроса:**
```json
{
"title": "Новое название",
"description": "Обновленное описание",
"learningMaterial": "Обновленный учебный материал",
"hiddenInstructions": "Обновленные инструкции"
}
```
## Получение данных
### Получение задания (GET /challenge/task/:taskId)
**Ответ содержит новое поле:**
```json
{
"id": "task_id",
"title": "Название задания",
"description": "Основное описание в Markdown",
"learningMaterial": "Дополнительный учебный материал в Markdown",
"createdAt": "2025-01-15T10:00:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}
```
**Важно:** Поле `learningMaterial` видно всем пользователям (студентам и преподавателям), в отличие от `hiddenInstructions`, которое скрывается от студентов.
### Получение всех заданий (GET /challenge/tasks)
Возвращает массив заданий с новым полем `learningMaterial`.
### Получение цепочек (GET /challenge/chains, GET /challenge/chain/:chainId)
При получении цепочек с populate заданий, поле `learningMaterial` будет доступно в каждом задании цепочки.
## Frontend изменения
### Интерфейсы TypeScript
```typescript
interface ChallengeTask {
id: string;
title: string;
description: string; // Markdown
learningMaterial?: string; // Новое поле - дополнительный материал в Markdown
createdAt: string;
updatedAt: string;
}
```
### Формы создания/редактирования заданий
В формах создания и редактирования заданий необходимо добавить поле для ввода `learningMaterial`:
```typescript
// Пример компонента формы
const TaskForm = () => {
const [formData, setFormData] = useState({
title: '',
description: '',
learningMaterial: '', // Новое поле
hiddenInstructions: ''
});
// Визуальный редактор или textarea для learningMaterial
return (
<form>
<input name="title" value={formData.title} />
<textarea name="description" value={formData.description} />
{/* Новое поле для дополнительного материала */}
<label>Дополнительный учебный материал (Markdown)</label>
<textarea
name="learningMaterial"
value={formData.learningMaterial}
placeholder="Дополнительные объяснения, ссылки, примеры..."
/>
{/* Только для преподавателей */}
<textarea name="hiddenInstructions" value={formData.hiddenInstructions} />
</form>
);
};
```
### Отображение заданий
При отображении задания студентам показывать `learningMaterial` как дополнительную информацию:
```typescript
const TaskView = ({ task }: { task: ChallengeTask }) => {
return (
<div>
<h1>{task.title}</h1>
{/* Основное описание */}
<div dangerouslySetInnerHTML={{ __html: marked(task.description) }} />
{/* Дополнительный учебный материал */}
{task.learningMaterial && (
<div className="learning-material">
<h2>Дополнительные материалы</h2>
<div dangerouslySetInnerHTML={{ __html: marked(task.learningMaterial) }} />
</div>
)}
</div>
);
};
```
## Миграция данных
Поле `learningMaterial` добавлено как необязательное с значением по умолчанию `''`, поэтому:
- Существующие задания будут работать без изменений
- Новое поле будет пустым для старых заданий
- Можно постепенно добавлять учебный материал к существующим заданиям
## Тестирование
### Создание задания с учебным материалом
```bash
# Создать задание с дополнительным материалом
POST /challenge/task
{
"title": "Тестовое задание",
"description": "Основное задание",
"learningMaterial": "# Полезная информация\n\nЭто дополнительный материал для студентов"
}
```
### Получение задания
```bash
GET /challenge/task/{taskId}
# Проверить, что learningMaterial присутствует в ответе
```
### Обновление учебного материала
```bash
PUT /challenge/task/{taskId}
{
"learningMaterial": "# Обновленная информация\n\nНовые полезные материалы..."
}
```
## Влияние на существующий код
- Все существующие эндпоинты получения данных автоматически возвращают новое поле
- Создание заданий без указания `learningMaterial` работает как прежде
- Фильтрация и валидация не затрагиваются
- Поле индексируется MongoDB автоматически
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "challenge",
"version": "1.2.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "challenge",
"version": "1.2.0",
"version": "1.3.0",
"license": "ISC",
"dependencies": {
"@brojs/cli": "^1.9.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "challenge",
"version": "1.2.0",
"version": "1.3.0",
"description": "",
"main": "./src/index.tsx",
"scripts": {
+1
View File
@@ -12,6 +12,7 @@ export interface ChallengeTask {
id: string
title: string
description: string
learningMaterial?: string
hiddenInstructions?: string
creator?: Record<string, unknown>
createdAt: string
@@ -0,0 +1,271 @@
import React, { useState, useMemo } from 'react'
import {
Box,
Button,
HStack,
Text,
VStack,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface LearningMaterialViewerProps {
content: string
linesPerPage?: number
}
export const LearningMaterialViewer = ({
content,
linesPerPage = 30
}: LearningMaterialViewerProps) => {
const [currentPage, setCurrentPage] = useState(0)
// Разделяем контент на страницы по linesPerPage строк
const pages = useMemo(() => {
const lines = content.split('\n')
const pagesArray: string[] = []
for (let i = 0; i < lines.length; i += linesPerPage) {
const pageLines = lines.slice(i, i + linesPerPage)
pagesArray.push(pageLines.join('\n'))
}
return pagesArray
}, [content, linesPerPage])
const totalPages = pages.length
if (totalPages === 0) {
return null
}
const goToPrevious = () => {
setCurrentPage(prev => Math.max(0, prev - 1))
}
const goToNext = () => {
setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))
}
return (
<Box
borderWidth="1px"
borderRadius="md"
borderColor="blue.200"
p={4}
bg="blue.50"
shadow="sm"
>
<VStack align="stretch" gap={3}>
<HStack justify="space-between" align="center">
<Text fontSize="lg" fontWeight="bold" color="blue.800">
Дополнительные материалы
</Text>
{totalPages > 1 && (
<Text fontSize="sm" color="blue.600">
Страница {currentPage + 1} из {totalPages}
</Text>
)}
</HStack>
<Box
color="gray.700"
fontSize="sm"
lineHeight="1.7"
css={{
// Заголовки
'& h1': {
fontSize: '1.75em',
fontWeight: '700',
marginTop: '1.2em',
marginBottom: '0.6em',
color: '#2D3748',
borderBottom: '2px solid #E2E8F0',
paddingBottom: '0.3em'
},
'& h2': {
fontSize: '1.5em',
fontWeight: '600',
marginTop: '1em',
marginBottom: '0.5em',
color: '#2D3748'
},
'& h3': {
fontSize: '1.25em',
fontWeight: '600',
marginTop: '0.8em',
marginBottom: '0.4em',
color: '#2D3748'
},
'& h4': {
fontSize: '1.1em',
fontWeight: '600',
marginTop: '0.6em',
marginBottom: '0.3em',
color: '#4A5568'
},
// Параграфы
'& p': {
marginTop: '0.75em',
marginBottom: '0.75em',
lineHeight: '1.8'
},
// Списки
'& ul, & ol': {
marginLeft: '1.5em',
marginTop: '0.75em',
marginBottom: '0.75em',
paddingLeft: '0.5em'
},
'& li': {
marginTop: '0.4em',
marginBottom: '0.4em',
lineHeight: '1.7'
},
'& li > p': {
marginTop: '0.25em',
marginBottom: '0.25em'
},
// Инлайн-код
'& code': {
backgroundColor: '#EDF2F7',
color: '#C53030',
padding: '0.15em 0.4em',
borderRadius: '4px',
fontSize: '0.9em',
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
fontWeight: '500'
},
// Блоки кода
'& pre': {
backgroundColor: '#1A202C',
color: '#E2E8F0',
padding: '1em 1.2em',
borderRadius: '8px',
overflowX: 'auto',
marginTop: '1em',
marginBottom: '1em',
border: '1px solid #2D3748',
fontSize: '0.9em',
lineHeight: '1.6'
},
'& pre code': {
backgroundColor: 'transparent',
color: '#E2E8F0',
padding: '0',
fontFamily: 'Monaco, Consolas, "Courier New", monospace'
},
// Цитаты
'& blockquote': {
borderLeft: '4px solid #4299E1',
paddingLeft: '1em',
paddingTop: '0.5em',
paddingBottom: '0.5em',
marginLeft: '0',
marginTop: '1em',
marginBottom: '1em',
fontStyle: 'italic',
color: '#4A5568',
backgroundColor: '#EBF8FF',
borderRadius: '0 4px 4px 0'
},
'& blockquote p': {
marginTop: '0.25em',
marginBottom: '0.25em'
},
// Ссылки
'& a': {
color: '#3182CE',
textDecoration: 'underline',
fontWeight: '500',
transition: 'color 0.2s',
'&:hover': {
color: '#2C5282'
}
},
// Горизонтальная линия
'& hr': {
border: 'none',
borderTop: '2px solid #E2E8F0',
marginTop: '1.5em',
marginBottom: '1.5em'
},
// Таблицы
'& table': {
borderCollapse: 'collapse',
width: '100%',
marginTop: '1em',
marginBottom: '1em',
fontSize: '0.95em'
},
'& table thead': {
backgroundColor: '#F7FAFC'
},
'& table th': {
border: '1px solid #E2E8F0',
padding: '0.75em 1em',
textAlign: 'left',
fontWeight: '600',
color: '#2D3748'
},
'& table td': {
border: '1px solid #E2E8F0',
padding: '0.75em 1em',
textAlign: 'left'
},
'& table tr:nth-of-type(even)': {
backgroundColor: '#F7FAFC'
},
// Выделение (strong, em)
'& strong': {
fontWeight: '600',
color: '#2D3748'
},
'& em': {
fontStyle: 'italic'
},
// Изображения
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: '8px',
marginTop: '1em',
marginBottom: '1em'
}
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{pages[currentPage]}
</ReactMarkdown>
</Box>
{totalPages > 1 && (
<HStack justify="center" gap={2}>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={goToPrevious}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={currentPage === 0}
leftIcon={<Text></Text>}
>
Предыдущая
</Button>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={goToNext}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={currentPage === totalPages - 1}
rightIcon={<Text></Text>}
>
Следующая
</Button>
</HStack>
)}
</VStack>
</Box>
)
}
+90 -60
View File
@@ -13,13 +13,15 @@ import remarkGfm from 'remark-gfm'
import type { ChallengeTask } from '../../__data__/types'
import { useChallenge } from '../../context/ChallengeContext'
import { useSubmission } from '../../hooks/useSubmission'
import { LearningMaterialViewer } from './LearningMaterialViewer'
interface TaskWorkspaceProps {
task: ChallengeTask
onTaskComplete?: () => void
onTaskSkip?: () => void
}
export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
export const TaskWorkspace = ({ task, onTaskComplete, onTaskSkip }: TaskWorkspaceProps) => {
const { refreshStats } = useChallenge()
const { result, setResult, submit, queueStatus, finalSubmission, isSubmitting } = useSubmission({
taskId: task.id,
@@ -27,31 +29,34 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
// Сохраняем последний результат, чтобы блок не исчезал
const [lastResult, setLastResult] = useState<typeof finalSubmission>(null)
// Состояние для показа дополнительного материала
const [showLearningMaterial, setShowLearningMaterial] = useState(false)
const isChecking = !!queueStatus || isSubmitting
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)
setShowLearningMaterial(false)
}, [task.id])
// Обновляем сохраненный результат только когда получаем новый
useEffect(() => {
if (finalSubmission) {
@@ -64,7 +69,7 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
refreshStats()
}
}, [finalSubmission, refreshStats])
// Используем либо текущий результат, либо последний сохраненный
const displayedSubmission = finalSubmission || lastResult
const showAccepted = displayedSubmission?.status === 'accepted'
@@ -72,61 +77,84 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
return (
<VStack align="stretch" gap={3}>
{/* Дополнительные материалы - показываются сверху */}
{task.learningMaterial && showLearningMaterial && (
<LearningMaterialViewer
content={task.learningMaterial}
linesPerPage={30}
/>
)}
<Box borderWidth="1px" borderRadius="md" borderColor="gray.200" p={4} bg="white" shadow="sm">
{/* Кнопка для показа дополнительного материала */}
{task.learningMaterial && !showLearningMaterial && (
<HStack justify="center" mb={4}>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={() => setShowLearningMaterial(true)}
>
📚 Прочитать доп. материал
</Button>
</HStack>
)}
<Text fontSize="lg" fontWeight="bold" mb={3} color="gray.800">
{task.title}
</Text>
<Box
color="gray.700"
fontSize="sm"
lineHeight="1.7"
css={{
// Заголовки
'& h1': {
fontSize: '1.75em',
fontWeight: '700',
marginTop: '1.2em',
'& h1': {
fontSize: '1.75em',
fontWeight: '700',
marginTop: '1.2em',
marginBottom: '0.6em',
color: '#2D3748',
borderBottom: '2px solid #E2E8F0',
paddingBottom: '0.3em'
},
'& h2': {
fontSize: '1.5em',
fontWeight: '600',
marginTop: '1em',
'& h2': {
fontSize: '1.5em',
fontWeight: '600',
marginTop: '1em',
marginBottom: '0.5em',
color: '#2D3748'
},
'& h3': {
fontSize: '1.25em',
fontWeight: '600',
marginTop: '0.8em',
'& h3': {
fontSize: '1.25em',
fontWeight: '600',
marginTop: '0.8em',
marginBottom: '0.4em',
color: '#2D3748'
},
'& h4': {
fontSize: '1.1em',
fontWeight: '600',
marginTop: '0.6em',
'& h4': {
fontSize: '1.1em',
fontWeight: '600',
marginTop: '0.6em',
marginBottom: '0.3em',
color: '#4A5568'
},
// Параграфы
'& p': {
marginTop: '0.75em',
'& p': {
marginTop: '0.75em',
marginBottom: '0.75em',
lineHeight: '1.8'
},
// Списки
'& ul, & ol': {
marginLeft: '1.5em',
marginTop: '0.75em',
'& ul, & ol': {
marginLeft: '1.5em',
marginTop: '0.75em',
marginBottom: '0.75em',
paddingLeft: '0.5em'
},
'& li': {
marginTop: '0.4em',
'& li': {
marginTop: '0.4em',
marginBottom: '0.4em',
lineHeight: '1.7'
},
@@ -135,41 +163,41 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
marginBottom: '0.25em'
},
// Инлайн-код
'& code': {
backgroundColor: '#EDF2F7',
'& code': {
backgroundColor: '#EDF2F7',
color: '#C53030',
padding: '0.15em 0.4em',
borderRadius: '4px',
padding: '0.15em 0.4em',
borderRadius: '4px',
fontSize: '0.9em',
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
fontWeight: '500'
},
// Блоки кода
'& pre': {
backgroundColor: '#1A202C',
'& pre': {
backgroundColor: '#1A202C',
color: '#E2E8F0',
padding: '1em 1.2em',
borderRadius: '8px',
overflowX: 'auto',
marginTop: '1em',
padding: '1em 1.2em',
borderRadius: '8px',
overflowX: 'auto',
marginTop: '1em',
marginBottom: '1em',
border: '1px solid #2D3748',
fontSize: '0.9em',
lineHeight: '1.6'
},
'& pre code': {
backgroundColor: 'transparent',
'& pre code': {
backgroundColor: 'transparent',
color: '#E2E8F0',
padding: '0',
fontFamily: 'Monaco, Consolas, "Courier New", monospace'
},
// Цитаты
'& blockquote': {
borderLeft: '4px solid #4299E1',
paddingLeft: '1em',
'& blockquote': {
borderLeft: '4px solid #4299E1',
paddingLeft: '1em',
paddingTop: '0.5em',
paddingBottom: '0.5em',
marginLeft: '0',
marginLeft: '0',
marginTop: '1em',
marginBottom: '1em',
fontStyle: 'italic',
@@ -182,8 +210,8 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
marginBottom: '0.25em'
},
// Ссылки
'& a': {
color: '#3182CE',
'& a': {
color: '#3182CE',
textDecoration: 'underline',
fontWeight: '500',
transition: 'color 0.2s',
@@ -246,14 +274,16 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
</Box>
</Box>
{/* Статус проверки и результат - фиксированное место */}
<Box minH="80px">
{queueStatus && !finalSubmission ? (
<Box
borderWidth="2px"
borderRadius="lg"
borderColor="blue.300"
bg="blue.50"
<Box
borderWidth="2px"
borderRadius="lg"
borderColor="blue.300"
bg="blue.50"
p={4}
>
<VStack gap={3} align="stretch">
@@ -262,13 +292,13 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
Проверяем решение...
</Text>
</HStack>
<Box>
{/* Кастомный прогресс-бар */}
<Box
bg="blue.100"
borderRadius="md"
h="24px"
<Box
bg="blue.100"
borderRadius="md"
h="24px"
overflow="hidden"
position="relative"
>
@@ -368,7 +398,7 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
{!isAccepted && (
<>
<Button
onClick={onTaskComplete}
onClick={onTaskSkip}
variant="outline"
size="sm"
colorScheme="gray"
+1 -1
View File
@@ -81,7 +81,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
const metricsCollector = useMemo(() => new MetricsCollector(), [])
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), [])
const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1 }), [])
const [userId, setUserId] = useState<string | null>(() => storage.getUserId())
const [nickname, setNickname] = useState<string | null>(() => storage.getNickname())
+105 -40
View File
@@ -51,11 +51,20 @@ export const TaskPage = () => {
return storage.getFurthestTaskIndex(chainId)
})
// Отслеживаем пропущенные задания
const [skippedTasks, setSkippedTasks] = useState<string[]>(() => {
if (!chainId) return []
return storage.getSkippedTasks(chainId)
})
// Обновляем furthestTaskIndex при изменении chainId или currentTaskIndex
useEffect(() => {
if (!chainId) return
const currentFurthest = storage.getFurthestTaskIndex(chainId)
setFurthestTaskIndex(currentFurthest)
// Также обновляем список пропущенных заданий
const currentSkipped = storage.getSkippedTasks(chainId)
setSkippedTasks(currentSkipped)
}, [chainId, currentTaskIndex])
// Сохраняем текущее состояние в storage и обновляем прогресс
@@ -77,35 +86,79 @@ export const TaskPage = () => {
return taskIndex <= furthestTaskIndex
}
const handleTaskComplete = () => {
if (!chain || currentTaskIndex === -1) return
const handleTaskSkip = () => {
if (!chain || currentTaskIndex === -1 || !taskId) return
// Добавляем задание в список пропущенных
storage.addSkippedTask(chain.id, taskId)
setSkippedTasks(storage.getSkippedTasks(chain.id))
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 currentSkipped = storage.getSkippedTasks(chain.id)
if (currentSkipped.length > 0) {
// Есть пропущенные задания - переходим к первому пропущенному
const firstSkippedId = currentSkipped[0]
navigate(URLs.task(chain.id, firstSkippedId))
} else {
// Нет пропущенных заданий - переходим на страницу завершения
storage.clearSessionData()
navigate(URLs.completed(chain.id))
}
}
}
const handleTaskComplete = () => {
if (!chain || currentTaskIndex === -1) return
// При успешном выполнении удаляем задание из пропущенных (если оно там было)
if (taskId) {
storage.removeSkippedTask(chain.id, taskId)
setSkippedTasks(storage.getSkippedTasks(chain.id))
}
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 {
// Достигнут конец списка заданий - проверяем пропущенные
const currentSkipped = storage.getSkippedTasks(chain.id)
if (currentSkipped.length > 0) {
// Есть пропущенные задания - переходим к первому пропущенному
const firstSkippedId = currentSkipped[0]
navigate(URLs.task(chain.id, firstSkippedId))
} 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) {
@@ -115,6 +168,7 @@ export const TaskPage = () => {
navigate(URLs.task(chain.id, newTaskId))
}
// Проверяем доступность текущего задания при загрузке
useEffect(() => {
if (chain && currentTaskIndex >= 0 && !isTaskAccessible(currentTaskIndex)) {
@@ -138,7 +192,7 @@ export const TaskPage = () => {
return null
}
const taskProgress = `Задание ${currentTaskIndex + 1}`
const taskProgress = `Задание`
return (
<>
@@ -146,12 +200,12 @@ export const TaskPage = () => {
<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}
<Box
bg="white"
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
p={3}
mb={4}
shadow="sm"
>
@@ -160,29 +214,40 @@ export const TaskPage = () => {
<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>
)
})}
{chain.tasks
.filter((_, index) => {
const isAccessible = isTaskAccessible(index)
// Показываем все доступные + только следующую недоступную
return isAccessible || index === furthestTaskIndex + 1
})
.map((t) => {
const taskIndex = chain.tasks.indexOf(t)
const isAccessible = isTaskAccessible(taskIndex)
const isCurrent = t.id === taskId
const isSkipped = skippedTasks.includes(t.id)
return (
<Button
key={t.id}
size="sm"
variant={isCurrent ? 'solid' : isAccessible ? 'outline' : 'ghost'}
colorScheme={isCurrent ? 'teal' : isSkipped ? 'gray' : 'gray'}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={!isAccessible}
onClick={() => isAccessible && handleNavigateToTask(t.id)}
minW="40px"
opacity={isAccessible ? (isSkipped ? 0.6 : 1) : 0.5}
cursor={isAccessible ? 'pointer' : 'not-allowed'}
bg={isSkipped && !isCurrent ? 'gray.200' : undefined}
color={isSkipped && !isCurrent ? 'gray.500' : undefined}
_hover={isAccessible ? (isSkipped ? { bg: 'gray.300' } : undefined) : undefined}
>
{isAccessible ? taskIndex + 1 : `🔒${taskIndex + 1}`}
</Button>
)
})}
</HStack>
<Button
size="sm"
variant="ghost"
@@ -194,7 +259,7 @@ export const TaskPage = () => {
</Flex>
</Box>
<TaskWorkspace task={task} onTaskComplete={handleTaskComplete} />
<TaskWorkspace task={task} onTaskComplete={handleTaskComplete} onTaskSkip={handleTaskSkip} />
</Box>
</Box>
</>
+1 -1
View File
@@ -16,7 +16,7 @@ export class PollingManager {
constructor(options: PollingOptions = {}) {
this.currentDelay = options.initialDelay ?? 2000
this.maxDelay = options.maxDelay ?? 10000
this.multiplier = options.multiplier ?? 1.5
this.multiplier = options.multiplier ?? 1.01
}
async start(callback: PollCallback) {
+42 -3
View File
@@ -16,8 +16,9 @@ export const STORAGE_KEYS = {
SELECTED_TASK_ID: 'challengeSelectedTaskId',
} as const
// Вспомогательная функция для ключа прогресса цепочки
// Вспомогательные функции для ключей
const getFurthestTaskKey = (chainId: string) => `challengeFurthestTask_${chainId}`
const getSkippedTasksKey = (chainId: string) => `challengeSkippedTasks_${chainId}`
// Получение значений
export const storage = {
@@ -121,11 +122,11 @@ export const storage = {
// Очистка всех прогрессов по цепочкам
clearAllChainProgress: (): void => {
if (!isBrowser()) return
// Перебираем все ключи localStorage и удаляем те, что начинаются с challengeFurthestTask_
// Перебираем все ключи localStorage и удаляем те, что начинаются с challengeFurthestTask_ или challengeSkippedTasks_
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith('challengeFurthestTask_')) {
if (key && (key.startsWith('challengeFurthestTask_') || key.startsWith('challengeSkippedTasks_'))) {
keysToRemove.push(key)
}
}
@@ -161,5 +162,43 @@ export const storage = {
if (!isBrowser()) return
localStorage.removeItem(getFurthestTaskKey(chainId))
},
// Получение пропущенных заданий для цепочки
getSkippedTasks: (chainId: string): string[] => {
if (!isBrowser()) return []
const value = localStorage.getItem(getSkippedTasksKey(chainId))
return value ? JSON.parse(value) : []
},
// Добавление задания в список пропущенных
addSkippedTask: (chainId: string, taskId: string): void => {
if (!isBrowser()) return
const skipped = storage.getSkippedTasks(chainId)
if (!skipped.includes(taskId)) {
skipped.push(taskId)
localStorage.setItem(getSkippedTasksKey(chainId), JSON.stringify(skipped))
}
},
// Удаление задания из списка пропущенных (когда оно выполнено)
removeSkippedTask: (chainId: string, taskId: string): void => {
if (!isBrowser()) return
const skipped = storage.getSkippedTasks(chainId)
const filtered = skipped.filter(id => id !== taskId)
localStorage.setItem(getSkippedTasksKey(chainId), JSON.stringify(filtered))
},
// Проверка, пропущено ли задание
isTaskSkipped: (chainId: string, taskId: string): boolean => {
if (!isBrowser()) return false
const skipped = storage.getSkippedTasks(chainId)
return skipped.includes(taskId)
},
// Очистка всех пропущенных заданий цепочки
clearSkippedTasks: (chainId: string): void => {
if (!isBrowser()) return
localStorage.removeItem(getSkippedTasksKey(chainId))
},
}
File diff suppressed because one or more lines are too long