Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05c291120d | |||
| 4fd2e5660c | |||
| 842bb959ab | |||
| eb5bc8a10a | |||
| a0ac758049 | |||
| 23ec02e29a | |||
| 1291519cda | |||
| d2783d01c9 | |||
| e29242accf | |||
| e6fae66881 | |||
| b9af3c4ee5 | |||
| f7df4c755d | |||
| 0092e55b65 |
202
UPDATE.md
Normal file
202
UPDATE.md
Normal 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 автоматически
|
||||
@@ -23,6 +23,7 @@ module.exports = {
|
||||
features: {
|
||||
'challenge': {
|
||||
// add your features here in the format [featureName]: { value: string }
|
||||
'task.skip.enabled': { value: 'true' }, // Включить кнопку пропуска задания
|
||||
},
|
||||
},
|
||||
config: {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "challenge",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "challenge",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.3",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@brojs/cli": "^1.9.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "challenge",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.3",
|
||||
"description": "",
|
||||
"main": "./src/index.tsx",
|
||||
"scripts": {
|
||||
|
||||
@@ -33,7 +33,7 @@ export const api = createApi({
|
||||
}),
|
||||
tagTypes: ['Chains', 'Chain', 'UserStats', 'SystemStats', 'Submissions', 'Queue'],
|
||||
endpoints: (builder) => ({
|
||||
authUser: builder.mutation<ChallengeAuthResponse, { nickname: string }>({
|
||||
authUser: builder.mutation<ChallengeAuthResponse, { nickname: string; workplaceNumber?: string }>({
|
||||
query: (body) => ({
|
||||
url: '/auth',
|
||||
method: 'POST',
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ChallengeTask {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
learningMaterial?: string
|
||||
hiddenInstructions?: string
|
||||
creator?: Record<string, unknown>
|
||||
createdAt: string
|
||||
|
||||
278
src/components/personal/LearningMaterialViewer.tsx
Normal file
278
src/components/personal/LearningMaterialViewer.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
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]}
|
||||
components={{
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a {...props} target="_blank" rel="noopener noreferrer" />
|
||||
)
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -9,49 +9,58 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { getFeatureValue } from '@brojs/cli'
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
// Проверяем, включена ли кнопка пропуска через feature flag
|
||||
const skipEnabled = getFeatureValue('challenge', 'task.skip.enabled', 'false').value === 'true'
|
||||
|
||||
// Сохраняем последний результат, чтобы блок не исчезал
|
||||
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 +73,7 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||
refreshStats()
|
||||
}
|
||||
}, [finalSubmission, refreshStats])
|
||||
|
||||
|
||||
// Используем либо текущий результат, либо последний сохраненный
|
||||
const displayedSubmission = finalSubmission || lastResult
|
||||
const showAccepted = displayedSubmission?.status === 'accepted'
|
||||
@@ -72,61 +81,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 +167,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 +214,8 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||
marginBottom: '0.25em'
|
||||
},
|
||||
// Ссылки
|
||||
'& a': {
|
||||
color: '#3182CE',
|
||||
'& a': {
|
||||
color: '#3182CE',
|
||||
textDecoration: 'underline',
|
||||
fontWeight: '500',
|
||||
transition: 'color 0.2s',
|
||||
@@ -242,18 +274,29 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{task.description}</ReactMarkdown>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a {...props} target="_blank" rel="noopener noreferrer" />
|
||||
)
|
||||
}}
|
||||
>
|
||||
{task.description}
|
||||
</ReactMarkdown>
|
||||
</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 +305,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"
|
||||
>
|
||||
@@ -367,16 +410,18 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
||||
<HStack justify="space-between" gap={2}>
|
||||
{!isAccepted && (
|
||||
<>
|
||||
<Button
|
||||
onClick={onTaskComplete}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
colorScheme="gray"
|
||||
// @ts-expect-error Chakra UI v2 uses isDisabled
|
||||
isDisabled={isChecking}
|
||||
>
|
||||
Пропустить
|
||||
</Button>
|
||||
{skipEnabled && (
|
||||
<Button
|
||||
onClick={onTaskSkip}
|
||||
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}>
|
||||
{needsRevision ? 'Отправить снова' : 'Отправить на проверку'}
|
||||
|
||||
@@ -65,6 +65,7 @@ interface ChallengeContextValue {
|
||||
login: (nickname: string, workplaceNumber: string) => Promise<void>
|
||||
logout: () => void
|
||||
refreshStats: () => Promise<void>
|
||||
refreshChains: () => Promise<void>
|
||||
eventEmitter: ChallengeEventEmitter
|
||||
pollingManager: PollingManager
|
||||
metricsCollector: MetricsCollector
|
||||
@@ -81,7 +82,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())
|
||||
@@ -96,10 +97,23 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
const [authUser, { isLoading: isAuthLoading }] = useAuthUserMutation()
|
||||
const [triggerStats, statsResult] = useLazyGetUserStatsQuery()
|
||||
|
||||
const { data: chainsData, isLoading: isChainsLoading } = useGetChainsQuery(undefined, {
|
||||
const {
|
||||
data: chainsData,
|
||||
isLoading: isChainsLoading,
|
||||
refetch: refetchChains,
|
||||
} = useGetChainsQuery(undefined, {
|
||||
skip: !userId,
|
||||
})
|
||||
|
||||
const refreshChains = useCallback(async () => {
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
cacheRef.current.clear('chains')
|
||||
await refetchChains()
|
||||
}, [refetchChains, userId])
|
||||
|
||||
useEffect(() => {
|
||||
if (chainsData) {
|
||||
setChains(chainsData)
|
||||
@@ -146,7 +160,10 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
const login = useCallback(
|
||||
async (nicknameValue: string, workplaceNumberValue: string) => {
|
||||
const response = await authUser({ nickname: nicknameValue }).unwrap()
|
||||
const response = await authUser({
|
||||
nickname: nicknameValue,
|
||||
workplaceNumber: workplaceNumberValue || undefined
|
||||
}).unwrap()
|
||||
setUserId(response.userId)
|
||||
setNickname(nicknameValue)
|
||||
setWorkplaceNumber(workplaceNumberValue)
|
||||
@@ -189,6 +206,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
login,
|
||||
logout,
|
||||
refreshStats,
|
||||
refreshChains,
|
||||
eventEmitter,
|
||||
pollingManager,
|
||||
metricsCollector,
|
||||
@@ -213,6 +231,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||
personalDashboard,
|
||||
pollingManager,
|
||||
refreshStats,
|
||||
refreshChains,
|
||||
saveDraft,
|
||||
stats,
|
||||
userId,
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ChallengeChain } from '../../__data__/types'
|
||||
|
||||
export const ChainsPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { nickname } = useChallenge()
|
||||
const { nickname, refreshChains } = useChallenge()
|
||||
|
||||
// Проверяем авторизацию
|
||||
useEffect(() => {
|
||||
@@ -24,6 +24,10 @@ export const ChainsPage = () => {
|
||||
}
|
||||
}, [navigate, nickname])
|
||||
|
||||
useEffect(() => {
|
||||
refreshChains()
|
||||
}, [refreshChains])
|
||||
|
||||
const handleSelectChain = (chain: ChallengeChain) => {
|
||||
storage.setSelectedChainId(chain.id)
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -24,7 +24,13 @@ router.use((req, res, next) => {
|
||||
|
||||
// Challenge API endpoints
|
||||
router.post('/challenge/auth', (req, res) => {
|
||||
res.json(readJson('auth.json'))
|
||||
const { nickname, workplaceNumber } = req.body
|
||||
const response = readJson('auth.json')
|
||||
|
||||
// Логируем для отладки
|
||||
console.log('Auth request:', { nickname, workplaceNumber })
|
||||
|
||||
res.json(response)
|
||||
})
|
||||
|
||||
router.get('/challenge/chains', (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user