13 Commits
v1.2.0 ... main

Author SHA1 Message Date
d983e1dce7 Enhance authentication flow by adding token refresh mechanism and improving error handling. Implement checks to prevent authentication loops during API calls and ensure token is updated before requests. This improves user experience and security in the application. 2025-12-18 12:11:31 +03:00
b2f947e699 1.5.2 2025-12-15 21:54:39 +03:00
43f73a129f Refactor TaskFormPage to split learning material into blocks of 30 lines for improved readability. Each block is displayed with a label indicating its order. This enhances the user experience by organizing content more effectively. 2025-12-15 21:53:45 +03:00
9bc5225c27 1.5.1 2025-12-15 21:25:02 +03:00
b69d00052f Add workplaceNumber field to user authentication and statistics API. Update frontend components and localization to support new field. Enhance user experience by displaying workplace information in relevant areas. 2025-12-15 21:22:06 +03:00
833d1cc14f 1.5.0 2025-12-14 21:16:58 +03:00
d624d63a37 Implement submissions polling interval feature in SubmissionsPage, allowing dynamic adjustment of API request frequency based on configuration. 2025-12-14 20:51:50 +03:00
7dab439f3a 1.4.0 2025-12-14 20:33:46 +03:00
c784626b33 Update SubmissionsPage to include polling interval for API requests, enhancing data retrieval efficiency. 2025-12-14 20:33:36 +03:00
6b7c773977 1.3.1 2025-12-14 16:33:15 +03:00
a748e608cf Comment out navigation to tasks after successful form submission in TaskFormPage to prevent unintended redirects during testing. 2025-12-14 16:25:35 +03:00
d0e26b02c7 1.3.0 2025-12-14 15:43:08 +03:00
4aae3c154e Add optional learningMaterial field to ChallengeTask model for additional educational content; update API endpoints, TypeScript interfaces, and frontend forms to support this feature. Enhance localization for English and Russian to include new field descriptions and placeholders. 2025-12-14 15:02:43 +03:00
19 changed files with 1124 additions and 18 deletions

582
WORKPLACE_NUMBER_API.md Normal file
View File

@@ -0,0 +1,582 @@
# API изменения: Поле workplaceNumber
## Обзор
Добавлено новое поле `workplaceNumber` для отслеживания рабочего места (компьютера), за которым работает ученик. Это поле сохраняется при авторизации и возвращается во всех эндпоинтах статистики.
---
## 1. Авторизация пользователя
### `POST /challenge/auth`
Регистрация или авторизация пользователя с указанием рабочего места.
#### Изменения
- ✨ Добавлен опциональный параметр `workplaceNumber`
- При создании нового пользователя сохраняется `workplaceNumber`
- При повторной авторизации существующего пользователя с другим `workplaceNumber` - значение обновляется
- Поиск пользователя по-прежнему выполняется только по `nickname`
#### Request
```http
POST /challenge/auth
Content-Type: application/json
{
"nickname": "student_ivan",
"workplaceNumber": "PC-15" // Опционально
}
```
#### Request Body Parameters
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `nickname` | `string` | ✅ Да | Никнейм пользователя (3-50 символов) |
| `workplaceNumber` | `string` | ❌ Нет | Номер рабочего места/компьютера (макс. 50 символов) |
#### Response
```json
{
"error": null,
"result": {
"ok": true,
"userId": "507f1f77bcf86cd799439011"
}
}
```
#### Примеры использования
**Первая авторизация с рабочим местом:**
```javascript
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-15'
})
});
```
**Повторная авторизация с другого места:**
```javascript
// Если пользователь пересел за другой компьютер
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-20' // Обновится в базе
})
});
```
**Авторизация без указания места:**
```javascript
// Работает как раньше, workplaceNumber необязателен
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan'
})
});
```
---
## 2. Статистика по цепочке заданий
### `GET /challenge/chain/:chainId/submissions`
Получение всех попыток по цепочке с данными о рабочих местах участников.
**Требует права:** `teacher` или `challenge-author`
#### Изменения
-В объекте `user` внутри `submissions` добавлено поле `workplaceNumber`
-В массиве `participants` добавлено поле `workplaceNumber`
#### Request
```http
GET /challenge/chain/507f1f77bcf86cd799439011/submissions?limit=50&offset=0
```
#### Query Parameters
| Параметр | Тип | Описание |
|----------|-----|----------|
| `userId` | `string` | Фильтр по конкретному пользователю |
| `status` | `string` | Фильтр по статусу: `pending`, `in_progress`, `accepted`, `needs_revision` |
| `limit` | `number` | Количество записей (по умолчанию: 100) |
| `offset` | `number` | Смещение для пагинации (по умолчанию: 0) |
#### Response
```json
{
"error": null,
"result": {
"chain": {
"id": "507f1f77bcf86cd799439011",
"name": "Основы Python",
"tasks": [
{
"id": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных"
}
]
},
"participants": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"completedTasks": 5,
"totalTasks": 10,
"progressPercent": 50
},
{
"userId": "507f1f77bcf86cd799439014",
"nickname": "student_maria",
"workplaceNumber": "PC-20", // ✨ Новое поле
"completedTasks": 8,
"totalTasks": 10,
"progressPercent": 80
}
],
"submissions": [
{
"id": "507f1f77bcf86cd799439015",
"user": {
"id": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15" // ✨ Новое поле
},
"task": {
"id": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных"
},
"status": "accepted",
"attemptNumber": 2,
"submittedAt": "2024-01-15T10:30:00.000Z",
"checkedAt": "2024-01-15T10:31:23.000Z",
"feedback": "Отличная работа!"
}
],
"pagination": {
"total": 150,
"limit": 50,
"offset": 0
}
}
}
```
#### Пример использования
```javascript
const chainId = '507f1f77bcf86cd799439011';
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
headers: {
'Authorization': 'Bearer YOUR_TOKEN' // Требуется токен преподавателя
}
});
const data = await response.json();
// Отобразить список участников с их местами
data.result.participants.forEach(participant => {
console.log(`${participant.nickname} (${participant.workplaceNumber}): ${participant.progressPercent}%`);
// Вывод: "student_ivan (PC-15): 50%"
});
```
---
## 3. Расширенная статистика системы
### `GET /challenge/stats/v2`
Получение детальной статистики с данными о рабочих местах участников.
#### Изменения
-В массиве `activeParticipants` добавлено поле `workplaceNumber`
-В `chainsDetailed[].participantProgress[]` добавлено поле `workplaceNumber`
#### Request
```http
GET /challenge/stats/v2
```
или с фильтром по конкретной цепочке:
```http
GET /challenge/stats/v2?chainId=507f1f77bcf86cd799439011
```
#### Query Parameters
| Параметр | Тип | Описание |
|----------|-----|----------|
| `chainId` | `string` | Опционально: фильтр по конкретной цепочке |
#### Response (фрагмент)
```json
{
"error": null,
"result": {
"users": 25,
"tasks": 50,
"chains": 5,
"submissions": {
"total": 342,
"accepted": 150,
"rejected": 80,
"pending": 12,
"inProgress": 100
},
"averageCheckTimeMs": 2500,
"queue": {
"pending": 5,
"processing": 2,
"completed": 335
},
"tasksTable": [
{
"taskId": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных",
"totalAttempts": 45,
"uniqueUsers": 20,
"acceptedCount": 18,
"successRate": 90,
"averageAttemptsToSuccess": 2.1
}
],
"activeParticipants": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"totalSubmissions": 25,
"completedTasks": 12,
"chainProgress": [
{
"chainId": "507f1f77bcf86cd799439011",
"chainName": "Основы Python",
"totalTasks": 10,
"completedTasks": 8,
"progressPercent": 80
}
]
}
],
"chainsDetailed": [
{
"chainId": "507f1f77bcf86cd799439011",
"name": "Основы Python",
"totalTasks": 10,
"tasks": [
{
"taskId": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных",
"description": "Изучите основные типы данных..."
}
],
"participantProgress": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"taskProgress": [
{
"taskId": "507f1f77bcf86cd799439012",
"taskTitle": "Переменные и типы данных",
"status": "completed"
}
],
"completedCount": 8,
"progressPercent": 80
}
]
}
]
}
}
```
#### Пример использования
```javascript
const response = await fetch('/challenge/stats/v2');
const data = await response.json();
// Создать карту класса с прогрессом
const classMap = data.result.activeParticipants.map(participant => ({
workplace: participant.workplaceNumber || 'Не указано',
student: participant.nickname,
progress: participant.completedTasks,
chains: participant.chainProgress
}));
// Отсортировать по номеру места
classMap.sort((a, b) => {
const numA = parseInt(a.workplace.replace(/\D/g, '')) || 0;
const numB = parseInt(b.workplace.replace(/\D/g, '')) || 0;
return numA - numB;
});
// Визуализация карты класса
classMap.forEach(item => {
console.log(`[${item.workplace}] ${item.student}: ${item.progress} заданий`);
});
// Вывод:
// [PC-15] student_ivan: 12 заданий
// [PC-20] student_maria: 15 заданий
```
---
## Примеры интеграции на фронтенде
### Компонент авторизации (React)
```jsx
import { useState } from 'react';
function LoginForm() {
const [nickname, setNickname] = useState('');
const [workplaceNumber, setWorkplaceNumber] = useState('');
const handleLogin = async (e) => {
e.preventDefault();
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname,
workplaceNumber: workplaceNumber || undefined
})
});
const data = await response.json();
if (data.result.ok) {
localStorage.setItem('userId', data.result.userId);
localStorage.setItem('nickname', nickname);
localStorage.setItem('workplaceNumber', workplaceNumber);
// Перенаправление на главную страницу
}
};
return (
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="Никнейм"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
required
/>
<input
type="text"
placeholder="Номер компьютера (опционально)"
value={workplaceNumber}
onChange={(e) => setWorkplaceNumber(e.target.value)}
/>
<button type="submit">Войти</button>
</form>
);
}
```
### Отображение карты класса (React)
```jsx
function ClassroomMap({ chainId }) {
const [participants, setParticipants] = useState([]);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const data = await response.json();
setParticipants(data.result.participants);
};
fetchData();
}, [chainId]);
return (
<div className="classroom-map">
<h2>Карта класса</h2>
<div className="grid">
{participants.map(participant => (
<div
key={participant.userId}
className="student-card"
>
<div className="workplace-badge">
{participant.workplaceNumber || 'N/A'}
</div>
<div className="student-info">
<strong>{participant.nickname}</strong>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${participant.progressPercent}%` }}
/>
</div>
<small>
{participant.completedTasks} / {participant.totalTasks} заданий
</small>
</div>
</div>
))}
</div>
</div>
);
}
```
### TypeScript интерфейсы
```typescript
// Типы для работы с новым API
interface AuthRequest {
nickname: string;
workplaceNumber?: string;
}
interface AuthResponse {
ok: boolean;
userId: string;
}
interface UserInfo {
id: string;
nickname: string;
workplaceNumber?: string; // ✨ Новое поле
}
interface Participant {
userId: string;
nickname: string;
workplaceNumber?: string; // ✨ Новое поле
completedTasks: number;
totalTasks: number;
progressPercent: number;
}
interface Submission {
id: string;
user: UserInfo; // Содержит workplaceNumber
task: {
id: string;
title: string;
};
status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision';
attemptNumber: number;
submittedAt: string;
checkedAt?: string;
feedback?: string;
}
interface ChainSubmissionsResponse {
chain: {
id: string;
name: string;
tasks: Array<{ id: string; title: string }>;
};
participants: Participant[];
submissions: Submission[];
pagination: {
total: number;
limit: number;
offset: number;
};
}
```
---
## Миграция существующего кода
### До (без workplaceNumber)
```javascript
// Старый код авторизации
await fetch('/challenge/auth', {
method: 'POST',
body: JSON.stringify({ nickname: 'student_ivan' })
});
// Старое отображение участников
participants.forEach(p => {
console.log(`${p.nickname}: ${p.progressPercent}%`);
});
```
### После (с workplaceNumber)
```javascript
// Новый код авторизации с местом
await fetch('/challenge/auth', {
method: 'POST',
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-15' // ✨ Добавлено
})
});
// Новое отображение участников
participants.forEach(p => {
const workplace = p.workplaceNumber ? `[${p.workplaceNumber}] ` : '';
console.log(`${workplace}${p.nickname}: ${p.progressPercent}%`);
// Вывод: "[PC-15] student_ivan: 50%"
});
```
---
## Обратная совместимость
**Все изменения обратно совместимы:**
- Поле `workplaceNumber` опционально при авторизации
- Старый код без `workplaceNumber` продолжит работать
- Если `workplaceNumber` не указан, в ответах будет `undefined`
- Поиск пользователей по-прежнему работает только по `nickname`
---
## Рекомендации
1. **При авторизации**: Всегда передавайте `workplaceNumber`, если он известен (например, определяйте автоматически по IP или позволяйте ученику выбрать)
2. **В UI**: Отображайте номер места рядом с именем ученика для удобства преподавателя
3. **Сортировка**: При отображении списка учеников сортируйте по `workplaceNumber` для соответствия физическому расположению
4. **Валидация**: Проверяйте формат `workplaceNumber` на фронте (например, "PC-01", "Место 15")
5. **Обновление**: Если ученик пересел, просто авторизуйтесь с новым `workplaceNumber` - значение автоматически обновится
---
## Вопросы и поддержка
При возникновении вопросов обращайтесь к бэкенд-команде или создавайте issue в репозитории проекта.

View File

@@ -23,6 +23,7 @@ module.exports = {
features: { features: {
'challenge-admin': { 'challenge-admin': {
'use-chain-submissions-api': { value: 'true' }, 'use-chain-submissions-api': { value: 'true' },
'submissions-polling-interval-ms': { value: '1200' },
}, },
}, },
config: { config: {

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 автоматически

View File

@@ -20,6 +20,9 @@
"challenge.admin.tasks.field.description": "Description (Markdown)", "challenge.admin.tasks.field.description": "Description (Markdown)",
"challenge.admin.tasks.field.description.placeholder": "# Task title\n\nTask description in Markdown format...", "challenge.admin.tasks.field.description.placeholder": "# Task title\n\nTask description in Markdown format...",
"challenge.admin.tasks.field.description.helper": "Use Markdown to format text", "challenge.admin.tasks.field.description.helper": "Use Markdown to format text",
"challenge.admin.tasks.field.learning.material": "Additional Learning Material (Markdown)",
"challenge.admin.tasks.field.learning.material.placeholder": "# Additional Materials\n\nTheory, links, solution examples...",
"challenge.admin.tasks.field.learning.material.helper": "Materials for in-depth study. Displayed with scrolling like a book.",
"challenge.admin.tasks.tab.editor": "Editor", "challenge.admin.tasks.tab.editor": "Editor",
"challenge.admin.tasks.tab.preview": "Preview", "challenge.admin.tasks.tab.preview": "Preview",
"challenge.admin.tasks.preview.empty": "Preview will appear here...", "challenge.admin.tasks.preview.empty": "Preview will appear here...",
@@ -155,6 +158,7 @@
"challenge.admin.users.empty.description": "Users will appear after registration", "challenge.admin.users.empty.description": "Users will appear after registration",
"challenge.admin.users.search.empty": "Nothing found for \"{query}\"", "challenge.admin.users.search.empty": "Nothing found for \"{query}\"",
"challenge.admin.users.table.nickname": "Nickname", "challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.workplace": "Workplace",
"challenge.admin.users.table.id": "ID", "challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Registration date", "challenge.admin.users.table.registered": "Registration date",
"challenge.admin.users.table.actions": "Actions", "challenge.admin.users.table.actions": "Actions",
@@ -207,6 +211,7 @@
"challenge.admin.submissions.search.empty.title": "Nothing found", "challenge.admin.submissions.search.empty.title": "Nothing found",
"challenge.admin.submissions.search.empty.description": "Try changing filters", "challenge.admin.submissions.search.empty.description": "Try changing filters",
"challenge.admin.submissions.table.user": "User", "challenge.admin.submissions.table.user": "User",
"challenge.admin.submissions.table.workplace": "Workplace",
"challenge.admin.submissions.table.task": "Task", "challenge.admin.submissions.table.task": "Task",
"challenge.admin.submissions.table.status": "Status", "challenge.admin.submissions.table.status": "Status",
"challenge.admin.submissions.table.attempt": "Attempt", "challenge.admin.submissions.table.attempt": "Attempt",

View File

@@ -19,6 +19,9 @@
"challenge.admin.tasks.field.description": "Описание (Markdown)", "challenge.admin.tasks.field.description": "Описание (Markdown)",
"challenge.admin.tasks.field.description.placeholder": "# Заголовок задания\n\nОписание задания в формате Markdown...", "challenge.admin.tasks.field.description.placeholder": "# Заголовок задания\n\nОписание задания в формате Markdown...",
"challenge.admin.tasks.field.description.helper": "Используйте Markdown для форматирования текста", "challenge.admin.tasks.field.description.helper": "Используйте Markdown для форматирования текста",
"challenge.admin.tasks.field.learning.material": "Дополнительный учебный материал (Markdown)",
"challenge.admin.tasks.field.learning.material.placeholder": "# Дополнительные материалы\n\nТеория, ссылки, примеры решений...",
"challenge.admin.tasks.field.learning.material.helper": "Материалы для углубленного изучения. Отображаются с прокруткой как книга.",
"challenge.admin.tasks.tab.editor": "Редактор", "challenge.admin.tasks.tab.editor": "Редактор",
"challenge.admin.tasks.tab.preview": "Превью", "challenge.admin.tasks.tab.preview": "Превью",
"challenge.admin.tasks.preview.empty": "Предпросмотр появится здесь...", "challenge.admin.tasks.preview.empty": "Предпросмотр появится здесь...",
@@ -154,6 +157,7 @@
"challenge.admin.users.empty.description": "Пользователи появятся после регистрации", "challenge.admin.users.empty.description": "Пользователи появятся после регистрации",
"challenge.admin.users.search.empty": "По запросу \"{query}\" ничего не найдено", "challenge.admin.users.search.empty": "По запросу \"{query}\" ничего не найдено",
"challenge.admin.users.table.nickname": "Nickname", "challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.workplace": "Место",
"challenge.admin.users.table.id": "ID", "challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Дата регистрации", "challenge.admin.users.table.registered": "Дата регистрации",
"challenge.admin.users.table.actions": "Действия", "challenge.admin.users.table.actions": "Действия",
@@ -206,6 +210,7 @@
"challenge.admin.submissions.search.empty.title": "Ничего не найдено", "challenge.admin.submissions.search.empty.title": "Ничего не найдено",
"challenge.admin.submissions.search.empty.description": "Попробуйте изменить фильтры", "challenge.admin.submissions.search.empty.description": "Попробуйте изменить фильтры",
"challenge.admin.submissions.table.user": "Пользователь", "challenge.admin.submissions.table.user": "Пользователь",
"challenge.admin.submissions.table.workplace": "Место",
"challenge.admin.submissions.table.task": "Задание", "challenge.admin.submissions.table.task": "Задание",
"challenge.admin.submissions.table.status": "Статус", "challenge.admin.submissions.table.status": "Статус",
"challenge.admin.submissions.table.attempt": "Попытка", "challenge.admin.submissions.table.attempt": "Попытка",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "challenge-admin-pl", "name": "challenge-admin-pl",
"version": "1.2.0", "version": "1.5.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "challenge-admin-pl", "name": "challenge-admin-pl",
"version": "1.2.0", "version": "1.5.2",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@brojs/cli": "^1.9.4", "@brojs/cli": "^1.9.4",

View File

@@ -1,6 +1,6 @@
{ {
"name": "challenge-admin", "name": "challenge-admin",
"version": "1.2.0", "version": "1.5.2",
"description": "", "description": "",
"main": "./src/index.tsx", "main": "./src/index.tsx",
"scripts": { "scripts": {

View File

@@ -31,15 +31,35 @@ export const api = createApi({
) => { ) => {
const response = await fetch(input, init) const response = await fetch(input, init)
if (response.status === 403) keycloak.login() if (response.status === 401 || response.status === 403) {
const { isAuthLoopBlocked, recordAuthAttempt } = await import('../../utils/authLoopGuard')
if (!isAuthLoopBlocked()) {
recordAuthAttempt()
keycloak.login()
} else {
console.error('Auth loop detected, not redirecting to login')
}
}
return response return response
}, },
headers: { headers: {
'Content-Type': 'application/json;charset=utf-8', 'Content-Type': 'application/json;charset=utf-8',
}, },
prepareHeaders: (headers) => { prepareHeaders: async (headers) => {
headers.set('Authorization', `Bearer ${keycloak.token}`) try {
// Обновить токен, если он истекает в течение 30 секунд
await keycloak.updateToken(30)
} catch (error) {
console.error('Failed to refresh token:', error)
}
if (keycloak.token) {
headers.set('Authorization', `Bearer ${keycloak.token}`)
}
return headers
}, },
}), }),
tagTypes: ['Task', 'Chain', 'User', 'Submission', 'Stats'], tagTypes: ['Task', 'Chain', 'User', 'Submission', 'Stats'],

View File

@@ -3,6 +3,6 @@ import Keycloak from 'keycloak-js'
export const keycloak = new Keycloak({ export const keycloak = new Keycloak({
url: KC_URL, url: KC_URL,
realm: KC_REALM, realm: KC_REALM,
clientId: KC_CLIENT_ID, clientId: KC_CLIENT_ID
}); })

View File

@@ -34,9 +34,22 @@ export const mount = async (Component, element = document.getElementById('app'))
recordAuthAttempt() recordAuthAttempt()
await keycloak.init({ await keycloak.init({
onLoad: 'login-required' onLoad: 'login-required',
checkLoginIframe: false
}) })
// Настройка автоматического обновления токена
setInterval(() => {
keycloak.updateToken(70).then((refreshed) => {
if (refreshed) {
console.log('Token was successfully refreshed')
}
}).catch(() => {
console.error('Failed to refresh token, redirecting to login')
keycloak.login()
})
}, 60000) // Проверять каждую минуту
const userInfo = await keycloak.loadUserInfo() const userInfo = await keycloak.loadUserInfo()
if (userInfo && keycloak.tokenParsed) { if (userInfo && keycloak.tokenParsed) {

View File

@@ -49,6 +49,11 @@ export const ParticipantsProgress: React.FC<ParticipantsProgressProps> = ({ part
<VStack align="stretch" gap={3}> <VStack align="stretch" gap={3}>
{/* Participant Header */} {/* Participant Header */}
<Box> <Box>
{participant.workplaceNumber && (
<Text fontSize="xs" color="gray.500" mb={1}>
{participant.workplaceNumber}
</Text>
)}
<Text fontSize="lg" fontWeight="bold" color="teal.700"> <Text fontSize="lg" fontWeight="bold" color="teal.700">
{participant.nickname} {participant.nickname}
</Text> </Text>

View File

@@ -41,9 +41,18 @@ export const SubmissionsPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { chainId } = useParams<{ chainId?: string }>() const { chainId } = useParams<{ chainId?: string }>()
// Проверяем feature flag // Проверяем feature flags
const featureValue = getFeatureValue('challenge-admin', 'use-chain-submissions-api') const featureValue = getFeatureValue('challenge-admin', 'use-chain-submissions-api')
const useNewApi = featureValue?.value === 'true' const useNewApi = featureValue?.value === 'true'
const pollingIntervalFeatureValue = getFeatureValue(
'challenge-admin',
'submissions-polling-interval-ms'
)
const pollingIntervalMs = (() => {
const rawValue = pollingIntervalFeatureValue?.value ?? ''
const parsed = Number.parseInt(rawValue, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1200
})()
// Состояние для выбранного пользователя и фильтров // Состояние для выбранного пользователя и фильтров
const [selectedUserId, setSelectedUserId] = useState<string | null>(null) const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
@@ -70,7 +79,10 @@ export const SubmissionsPage: React.FC = () => {
userId: selectedUserId || undefined, userId: selectedUserId || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined, status: statusFilter !== 'all' ? statusFilter : undefined,
}, },
{ skip: !chainId || !useNewApi } {
skip: !chainId || !useNewApi,
pollingInterval: pollingIntervalMs,
}
) )
// Старый API: получаем общую статистику и submissions отдельно // Старый API: получаем общую статистику и submissions отдельно
@@ -470,9 +482,16 @@ export const SubmissionsPage: React.FC = () => {
transition="all 0.2s" transition="all 0.2s"
> >
<HStack justify="space-between" mb={2} gap={2}> <HStack justify="space-between" mb={2} gap={2}>
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px"> <VStack align="start" gap={0}>
{participant.nickname} {participant.workplaceNumber && (
</Text> <Text fontSize="xs" color="gray.500">
{participant.workplaceNumber}
</Text>
)}
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px">
{participant.nickname}
</Text>
</VStack>
<Badge colorPalette={colorPalette} size="sm"> <Badge colorPalette={colorPalette} size="sm">
{participant.progressPercent}% {participant.progressPercent}%
</Badge> </Badge>
@@ -508,6 +527,7 @@ export const SubmissionsPage: React.FC = () => {
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.user')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.user')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.workplace')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.task')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.task')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.status')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.status')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.attempt')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.attempt')}</Table.ColumnHeader>
@@ -530,6 +550,11 @@ export const SubmissionsPage: React.FC = () => {
? rawUser ? rawUser
: '' : ''
const workplaceNumber =
rawUser && typeof rawUser === 'object' && 'workplaceNumber' in rawUser
? rawUser.workplaceNumber ?? ''
: ''
const title = const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '') ? (rawTask.title ?? '')
@@ -540,6 +565,11 @@ export const SubmissionsPage: React.FC = () => {
return ( return (
<Table.Row key={submission.id}> <Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{nickname}</Table.Cell> <Table.Cell fontWeight="medium">{nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{workplaceNumber || '—'}
</Text>
</Table.Cell>
<Table.Cell>{title}</Table.Cell> <Table.Cell>{title}</Table.Cell>
<Table.Cell> <Table.Cell>
<StatusBadge status={submission.status} /> <StatusBadge status={submission.status} />

View File

@@ -26,6 +26,20 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert' import { ErrorAlert } from '../../components/ErrorAlert'
import { toaster } from '../../components/ui/toaster' import { toaster } from '../../components/ui/toaster'
// Функция для разбиения текста на блоки по 30 строк
const splitTextIntoBlocks = (text: string, linesPerBlock: number = 30): string[] => {
if (!text) return []
const lines = text.split('\n')
const blocks: string[] = []
for (let i = 0; i < lines.length; i += linesPerBlock) {
const block = lines.slice(i, i + linesPerBlock).join('\n')
blocks.push(block)
}
return blocks
}
export const TaskFormPage: React.FC = () => { export const TaskFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -40,6 +54,7 @@ export const TaskFormPage: React.FC = () => {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [learningMaterial, setLearningMaterial] = useState('')
const [hiddenInstructions, setHiddenInstructions] = useState('') const [hiddenInstructions, setHiddenInstructions] = useState('')
const [showDescPreview, setShowDescPreview] = useState(false) const [showDescPreview, setShowDescPreview] = useState(false)
const [testAnswer, setTestAnswer] = useState('') const [testAnswer, setTestAnswer] = useState('')
@@ -50,6 +65,7 @@ export const TaskFormPage: React.FC = () => {
if (task) { if (task) {
setTitle(task.title) setTitle(task.title)
setDescription(task.description) setDescription(task.description)
setLearningMaterial(task.learningMaterial || '')
setHiddenInstructions(task.hiddenInstructions || '') setHiddenInstructions(task.hiddenInstructions || '')
} }
}, [task]) }, [task])
@@ -106,6 +122,7 @@ export const TaskFormPage: React.FC = () => {
data: { data: {
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
learningMaterial: learningMaterial.trim() || undefined,
hiddenInstructions: hiddenInstructions.trim() || undefined, hiddenInstructions: hiddenInstructions.trim() || undefined,
}, },
}).unwrap() }).unwrap()
@@ -118,6 +135,7 @@ export const TaskFormPage: React.FC = () => {
await createTask({ await createTask({
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
learningMaterial: learningMaterial.trim() || undefined,
hiddenInstructions: hiddenInstructions.trim() || undefined, hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
@@ -126,7 +144,7 @@ export const TaskFormPage: React.FC = () => {
type: 'success', type: 'success',
}) })
} }
navigate(URLs.tasks) // navigate(URLs.tasks)
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = const errorMessage =
(err && typeof err === 'object' && 'data' in err && (err && typeof err === 'object' && 'data' in err &&
@@ -355,6 +373,178 @@ export const TaskFormPage: React.FC = () => {
<Field.HelperText>{t('challenge.admin.tasks.field.description.helper')}</Field.HelperText> <Field.HelperText>{t('challenge.admin.tasks.field.description.helper')}</Field.HelperText>
</Field.Root> </Field.Root>
{/* Learning Material */}
<Field.Root>
<Field.Label>{t('challenge.admin.tasks.field.learning.material')}</Field.Label>
<Box display={{ base: 'block', lg: 'none' }}>
{/* Табы для мобильных */}
<Tabs.Root
value={showDescPreview ? 'preview' : 'editor'}
onValueChange={(e) => setShowDescPreview(e.value === 'preview')}
>
<Tabs.List>
<Tabs.Trigger value="editor">{t('challenge.admin.tasks.tab.editor')}</Tabs.Trigger>
<Tabs.Trigger value="preview">{t('challenge.admin.tasks.tab.preview')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="editor" pt={4}>
<Textarea
value={learningMaterial}
onChange={(e) => setLearningMaterial(e.target.value)}
placeholder={t('challenge.admin.tasks.field.learning.material.placeholder')}
rows={12}
fontFamily="monospace"
disabled={isLoading}
/>
</Tabs.Content>
<Tabs.Content value="preview" pt={4}>
<Box
p={4}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
minH="250px"
maxH="400px"
bg="gray.50"
overflowY="auto"
>
{learningMaterial ? (
<VStack align="stretch" gap={4}>
{splitTextIntoBlocks(learningMaterial, 30).map((block, index) => (
<Box
key={index}
p={4}
borderWidth="2px"
borderColor="teal.200"
borderRadius="md"
bg="white"
position="relative"
className="markdown-preview"
css={{
'& a': {
color: '#0f766e',
textDecoration: 'underline',
cursor: 'pointer',
'&:hover': {
color: '#115e59',
}
}
}}
>
<Box
position="absolute"
top="-10px"
left="10px"
bg="teal.500"
color="white"
px={2}
py={1}
borderRadius="md"
fontSize="xs"
fontWeight="bold"
>
Блок {index + 1} (30 строк)
</Box>
<ReactMarkdown>{block}</ReactMarkdown>
</Box>
))}
</VStack>
) : (
<Text color="gray.400" fontStyle="italic">
{t('challenge.admin.tasks.preview.empty')}
</Text>
)}
</Box>
</Tabs.Content>
</Tabs.Root>
</Box>
{/* Две колонки для десктопа */}
<Grid
display={{ base: 'none', lg: 'grid' }}
templateColumns="1fr 1fr"
gap={4}
mt={2}
>
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2} color="gray.700">
{t('challenge.admin.tasks.tab.editor')}
</Text>
<Textarea
value={learningMaterial}
onChange={(e) => setLearningMaterial(e.target.value)}
placeholder={t('challenge.admin.tasks.field.learning.material.placeholder')}
rows={15}
fontFamily="monospace"
disabled={isLoading}
/>
</Box>
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2} color="gray.700">
{t('challenge.admin.tasks.tab.preview')}
</Text>
<Box
p={4}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
minH="250px"
maxH="400px"
bg="gray.50"
overflowY="auto"
height="100%"
>
{learningMaterial ? (
<VStack align="stretch" gap={4}>
{splitTextIntoBlocks(learningMaterial, 30).map((block, index) => (
<Box
key={index}
p={4}
borderWidth="2px"
borderColor="teal.200"
borderRadius="md"
bg="white"
position="relative"
className="markdown-preview"
css={{
'& a': {
color: '#0f766e',
textDecoration: 'underline',
cursor: 'pointer',
'&:hover': {
color: '#115e59',
}
}
}}
>
<Box
position="absolute"
top="-10px"
left="10px"
bg="teal.500"
color="white"
px={2}
py={1}
borderRadius="md"
fontSize="xs"
fontWeight="bold"
>
Блок {index + 1} (30 строк)
</Box>
<ReactMarkdown>{block}</ReactMarkdown>
</Box>
))}
</VStack>
) : (
<Text color="gray.400" fontStyle="italic">
{t('challenge.admin.tasks.preview.empty')}
</Text>
)}
</Box>
</Box>
</Grid>
<Field.HelperText>{t('challenge.admin.tasks.field.learning.material.helper')}</Field.HelperText>
</Field.Root>
{/* Hidden Instructions */} {/* Hidden Instructions */}
<Field.Root> <Field.Root>
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200"> <Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">

View File

@@ -61,6 +61,7 @@ export const UsersPage: React.FC = () => {
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.table.workplace')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.table.id')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.table.id')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.stats.total.submissions')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.stats.total.submissions')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.stats.completed')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.stats.completed')}</Table.ColumnHeader>
@@ -71,6 +72,11 @@ export const UsersPage: React.FC = () => {
{filteredUsers.map((user) => ( {filteredUsers.map((user) => (
<Table.Row key={user.userId}> <Table.Row key={user.userId}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell> <Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{user.workplaceNumber || '—'}
</Text>
</Table.Cell>
<Table.Cell> <Table.Cell>
<Text fontSize="xs" fontFamily="monospace" color="gray.600"> <Text fontSize="xs" fontFamily="monospace" color="gray.600">
{user.userId} {user.userId}

View File

@@ -4,6 +4,7 @@ export interface ChallengeUser {
_id: string _id: string
id: string id: string
nickname: string nickname: string
workplaceNumber?: string
createdAt: string createdAt: string
} }
@@ -12,6 +13,7 @@ export interface ChallengeTask {
id: string id: string
title: string title: string
description: string // Markdown description: string // Markdown
learningMaterial?: string // Дополнительный учебный материал в Markdown
hiddenInstructions?: string // Только для преподавателей hiddenInstructions?: string // Только для преподавателей
creator?: { creator?: {
sub: string sub: string
@@ -121,12 +123,14 @@ export interface APIResponse<T> {
export interface CreateTaskRequest { export interface CreateTaskRequest {
title: string title: string
description: string description: string
learningMaterial?: string
hiddenInstructions?: string hiddenInstructions?: string
} }
export interface UpdateTaskRequest { export interface UpdateTaskRequest {
title?: string title?: string
description?: string description?: string
learningMaterial?: string
hiddenInstructions?: string hiddenInstructions?: string
} }
@@ -177,6 +181,7 @@ export interface ChainProgress {
export interface ActiveParticipant { export interface ActiveParticipant {
userId: string userId: string
nickname: string nickname: string
workplaceNumber?: string
totalSubmissions: number totalSubmissions: number
completedTasks: number completedTasks: number
chainProgress: ChainProgress[] chainProgress: ChainProgress[]
@@ -191,6 +196,7 @@ export interface TaskProgress {
export interface ParticipantProgress { export interface ParticipantProgress {
userId: string userId: string
nickname: string nickname: string
workplaceNumber?: string
taskProgress: TaskProgress[] taskProgress: TaskProgress[]
completedCount: number completedCount: number
progressPercent: number progressPercent: number
@@ -259,6 +265,7 @@ export interface TestSubmissionResult {
export interface ChainSubmissionsParticipant { export interface ChainSubmissionsParticipant {
userId: string userId: string
nickname: string nickname: string
workplaceNumber?: string
completedTasks: number completedTasks: number
totalTasks: number totalTasks: number
progressPercent: number progressPercent: number

View File

@@ -203,6 +203,7 @@
{ {
"userId": "6909b51512c75d75a36a52bf", "userId": "6909b51512c75d75a36a52bf",
"nickname": "Примаков А.А.", "nickname": "Примаков А.А.",
"workplaceNumber": "PC-07",
"totalSubmissions": 14, "totalSubmissions": 14,
"completedTasks": 1, "completedTasks": 1,
"chainProgress": [ "chainProgress": [
@@ -225,6 +226,7 @@
{ {
"userId": "user_1", "userId": "user_1",
"nickname": "alex_dev", "nickname": "alex_dev",
"workplaceNumber": "PC-01",
"totalSubmissions": 18, "totalSubmissions": 18,
"completedTasks": 12, "completedTasks": 12,
"chainProgress": [ "chainProgress": [
@@ -247,6 +249,7 @@
{ {
"userId": "user_2", "userId": "user_2",
"nickname": "maria_coder", "nickname": "maria_coder",
"workplaceNumber": "PC-05",
"totalSubmissions": 15, "totalSubmissions": 15,
"completedTasks": 9, "completedTasks": 9,
"chainProgress": [ "chainProgress": [
@@ -269,6 +272,7 @@
{ {
"userId": "user_3", "userId": "user_3",
"nickname": "ivan_programmer", "nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"totalSubmissions": 10, "totalSubmissions": 10,
"completedTasks": 5, "completedTasks": 5,
"chainProgress": [ "chainProgress": [
@@ -291,6 +295,7 @@
{ {
"userId": "user_4", "userId": "user_4",
"nickname": "kate_fullstack", "nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"totalSubmissions": 22, "totalSubmissions": 22,
"completedTasks": 15, "completedTasks": 15,
"chainProgress": [ "chainProgress": [
@@ -313,6 +318,7 @@
{ {
"userId": "user_5", "userId": "user_5",
"nickname": "dmitry_backend", "nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"totalSubmissions": 12, "totalSubmissions": 12,
"completedTasks": 6, "completedTasks": 6,
"chainProgress": [ "chainProgress": [
@@ -335,6 +341,7 @@
{ {
"userId": "user_6", "userId": "user_6",
"nickname": "anna_react", "nickname": "anna_react",
"workplaceNumber": "PC-08",
"totalSubmissions": 14, "totalSubmissions": 14,
"completedTasks": 7, "completedTasks": 7,
"chainProgress": [ "chainProgress": [
@@ -376,6 +383,7 @@
{ {
"userId": "user_1", "userId": "user_1",
"nickname": "alex_dev", "nickname": "alex_dev",
"workplaceNumber": "PC-01",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -394,6 +402,7 @@
{ {
"userId": "user_2", "userId": "user_2",
"nickname": "maria_coder", "nickname": "maria_coder",
"workplaceNumber": "PC-05",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -412,6 +421,7 @@
{ {
"userId": "user_3", "userId": "user_3",
"nickname": "ivan_programmer", "nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -430,6 +440,7 @@
{ {
"userId": "user_4", "userId": "user_4",
"nickname": "kate_fullstack", "nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -448,6 +459,7 @@
{ {
"userId": "user_5", "userId": "user_5",
"nickname": "dmitry_backend", "nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -466,6 +478,7 @@
{ {
"userId": "user_6", "userId": "user_6",
"nickname": "anna_react", "nickname": "anna_react",
"workplaceNumber": "PC-08",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "pending" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "pending" },
@@ -503,6 +516,7 @@
{ {
"userId": "user_1", "userId": "user_1",
"nickname": "alex_dev", "nickname": "alex_dev",
"workplaceNumber": "PC-01",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -521,6 +535,7 @@
{ {
"userId": "user_2", "userId": "user_2",
"nickname": "maria_coder", "nickname": "maria_coder",
"workplaceNumber": "PC-05",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -539,6 +554,7 @@
{ {
"userId": "user_3", "userId": "user_3",
"nickname": "ivan_programmer", "nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -557,6 +573,7 @@
{ {
"userId": "user_4", "userId": "user_4",
"nickname": "kate_fullstack", "nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -575,6 +592,7 @@
{ {
"userId": "user_5", "userId": "user_5",
"nickname": "dmitry_backend", "nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "pending" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "pending" },
@@ -593,6 +611,7 @@
{ {
"userId": "user_6", "userId": "user_6",
"nickname": "anna_react", "nickname": "anna_react",
"workplaceNumber": "PC-08",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },

View File

@@ -6,6 +6,7 @@
"_id": "user001", "_id": "user001",
"id": "user001", "id": "user001",
"nickname": "alex_student", "nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z" "createdAt": "2024-10-15T08:30:00.000Z"
}, },
"task": { "task": {
@@ -31,6 +32,7 @@
"_id": "user001", "_id": "user001",
"id": "user001", "id": "user001",
"nickname": "alex_student", "nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z" "createdAt": "2024-10-15T08:30:00.000Z"
}, },
"task": { "task": {
@@ -56,6 +58,7 @@
"_id": "user002", "_id": "user002",
"id": "user002", "id": "user002",
"nickname": "maria_dev", "nickname": "maria_dev",
"workplaceNumber": "PC-05",
"createdAt": "2024-10-16T10:15:00.000Z" "createdAt": "2024-10-16T10:15:00.000Z"
}, },
"task": { "task": {
@@ -81,6 +84,7 @@
"_id": "user003", "_id": "user003",
"id": "user003", "id": "user003",
"nickname": "ivan_coder", "nickname": "ivan_coder",
"workplaceNumber": "PC-12",
"createdAt": "2024-10-17T14:20:00.000Z" "createdAt": "2024-10-17T14:20:00.000Z"
}, },
"task": { "task": {
@@ -106,6 +110,7 @@
"_id": "user004", "_id": "user004",
"id": "user004", "id": "user004",
"nickname": "olga_js", "nickname": "olga_js",
"workplaceNumber": "PC-03",
"createdAt": "2024-10-18T09:00:00.000Z" "createdAt": "2024-10-18T09:00:00.000Z"
}, },
"task": { "task": {
@@ -131,6 +136,7 @@
"_id": "user005", "_id": "user005",
"id": "user005", "id": "user005",
"nickname": "dmitry_react", "nickname": "dmitry_react",
"workplaceNumber": "PC-15",
"createdAt": "2024-10-20T11:45:00.000Z" "createdAt": "2024-10-20T11:45:00.000Z"
}, },
"task": { "task": {
@@ -156,6 +162,7 @@
"_id": "user006", "_id": "user006",
"id": "user006", "id": "user006",
"nickname": "anna_frontend", "nickname": "anna_frontend",
"workplaceNumber": "PC-08",
"createdAt": "2024-10-22T16:30:00.000Z" "createdAt": "2024-10-22T16:30:00.000Z"
}, },
"task": { "task": {

View File

@@ -3,36 +3,42 @@
"_id": "user001", "_id": "user001",
"id": "user001", "id": "user001",
"nickname": "alex_student", "nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z" "createdAt": "2024-10-15T08:30:00.000Z"
}, },
{ {
"_id": "user002", "_id": "user002",
"id": "user002", "id": "user002",
"nickname": "maria_dev", "nickname": "maria_dev",
"workplaceNumber": "PC-05",
"createdAt": "2024-10-16T10:15:00.000Z" "createdAt": "2024-10-16T10:15:00.000Z"
}, },
{ {
"_id": "user003", "_id": "user003",
"id": "user003", "id": "user003",
"nickname": "ivan_coder", "nickname": "ivan_coder",
"workplaceNumber": "PC-12",
"createdAt": "2024-10-17T14:20:00.000Z" "createdAt": "2024-10-17T14:20:00.000Z"
}, },
{ {
"_id": "user004", "_id": "user004",
"id": "user004", "id": "user004",
"nickname": "olga_js", "nickname": "olga_js",
"workplaceNumber": "PC-03",
"createdAt": "2024-10-18T09:00:00.000Z" "createdAt": "2024-10-18T09:00:00.000Z"
}, },
{ {
"_id": "user005", "_id": "user005",
"id": "user005", "id": "user005",
"nickname": "dmitry_react", "nickname": "dmitry_react",
"workplaceNumber": "PC-15",
"createdAt": "2024-10-20T11:45:00.000Z" "createdAt": "2024-10-20T11:45:00.000Z"
}, },
{ {
"_id": "user006", "_id": "user006",
"id": "user006", "id": "user006",
"nickname": "anna_frontend", "nickname": "anna_frontend",
"workplaceNumber": "PC-08",
"createdAt": "2024-10-22T16:30:00.000Z" "createdAt": "2024-10-22T16:30:00.000Z"
}, },
{ {
@@ -45,6 +51,7 @@
"_id": "user008", "_id": "user008",
"id": "user008", "id": "user008",
"nickname": "elena_fullstack", "nickname": "elena_fullstack",
"workplaceNumber": "PC-20",
"createdAt": "2024-10-28T10:00:00.000Z" "createdAt": "2024-10-28T10:00:00.000Z"
} }
] ]

View File

@@ -604,18 +604,24 @@ router.get('/challenge/chain/:chainId/submissions', (req, res) => {
filteredSubmissions.forEach(sub => { filteredSubmissions.forEach(sub => {
const subUserId = typeof sub.user === 'object' ? sub.user.id : sub.user; const subUserId = typeof sub.user === 'object' ? sub.user.id : sub.user;
const subUserNickname = typeof sub.user === 'object' ? sub.user.nickname : ''; const subUserNickname = typeof sub.user === 'object' ? sub.user.nickname : '';
const subUserWorkplaceNumber = typeof sub.user === 'object' ? sub.user.workplaceNumber : undefined;
// Найти nickname если не заполнен // Найти nickname и workplaceNumber если не заполнены
let nickname = subUserNickname; let nickname = subUserNickname;
if (!nickname) { let workplaceNumber = subUserWorkplaceNumber;
if (!nickname || !workplaceNumber) {
const user = users.find(u => u.id === subUserId); const user = users.find(u => u.id === subUserId);
nickname = user ? user.nickname : subUserId; if (user) {
nickname = nickname || user.nickname || subUserId;
workplaceNumber = workplaceNumber || user.workplaceNumber;
}
} }
if (!participantMap.has(subUserId)) { if (!participantMap.has(subUserId)) {
participantMap.set(subUserId, { participantMap.set(subUserId, {
userId: subUserId, userId: subUserId,
nickname: nickname, nickname: nickname,
workplaceNumber: workplaceNumber,
completedTasks: new Set(), completedTasks: new Set(),
totalTasks: chain.tasks.length, totalTasks: chain.tasks.length,
}); });
@@ -632,6 +638,7 @@ router.get('/challenge/chain/:chainId/submissions', (req, res) => {
const participants = Array.from(participantMap.values()).map(p => ({ const participants = Array.from(participantMap.values()).map(p => ({
userId: p.userId, userId: p.userId,
nickname: p.nickname, nickname: p.nickname,
workplaceNumber: p.workplaceNumber,
completedTasks: p.completedTasks.size, completedTasks: p.completedTasks.size,
totalTasks: p.totalTasks, totalTasks: p.totalTasks,
progressPercent: p.totalTasks > 0 progressPercent: p.totalTasks > 0