Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2f947e699 | |||
| 43f73a129f | |||
| 9bc5225c27 | |||
| b69d00052f | |||
| 833d1cc14f | |||
| d624d63a37 | |||
| 7dab439f3a | |||
| c784626b33 | |||
| 6b7c773977 | |||
| a748e608cf | |||
| d0e26b02c7 | |||
| 4aae3c154e | |||
| e93de750fc |
582
WORKPLACE_NUMBER_API.md
Normal file
582
WORKPLACE_NUMBER_API.md
Normal 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 в репозитории проекта.
|
||||
@@ -23,6 +23,7 @@ module.exports = {
|
||||
features: {
|
||||
'challenge-admin': {
|
||||
'use-chain-submissions-api': { value: 'true' },
|
||||
'submissions-polling-interval-ms': { value: '1200' },
|
||||
},
|
||||
},
|
||||
config: {
|
||||
|
||||
202
docs/CHALLENGE_LEARNING_MATERIAL.md
Normal file
202
docs/CHALLENGE_LEARNING_MATERIAL.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 автоматически
|
||||
@@ -20,6 +20,9 @@
|
||||
"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.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.preview": "Preview",
|
||||
"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.search.empty": "Nothing found for \"{query}\"",
|
||||
"challenge.admin.users.table.nickname": "Nickname",
|
||||
"challenge.admin.users.table.workplace": "Workplace",
|
||||
"challenge.admin.users.table.id": "ID",
|
||||
"challenge.admin.users.table.registered": "Registration date",
|
||||
"challenge.admin.users.table.actions": "Actions",
|
||||
@@ -207,6 +211,7 @@
|
||||
"challenge.admin.submissions.search.empty.title": "Nothing found",
|
||||
"challenge.admin.submissions.search.empty.description": "Try changing filters",
|
||||
"challenge.admin.submissions.table.user": "User",
|
||||
"challenge.admin.submissions.table.workplace": "Workplace",
|
||||
"challenge.admin.submissions.table.task": "Task",
|
||||
"challenge.admin.submissions.table.status": "Status",
|
||||
"challenge.admin.submissions.table.attempt": "Attempt",
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"challenge.admin.tasks.field.description": "Описание (Markdown)",
|
||||
"challenge.admin.tasks.field.description.placeholder": "# Заголовок задания\n\nОписание задания в формате 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.preview": "Превью",
|
||||
"challenge.admin.tasks.preview.empty": "Предпросмотр появится здесь...",
|
||||
@@ -154,6 +157,7 @@
|
||||
"challenge.admin.users.empty.description": "Пользователи появятся после регистрации",
|
||||
"challenge.admin.users.search.empty": "По запросу \"{query}\" ничего не найдено",
|
||||
"challenge.admin.users.table.nickname": "Nickname",
|
||||
"challenge.admin.users.table.workplace": "Место",
|
||||
"challenge.admin.users.table.id": "ID",
|
||||
"challenge.admin.users.table.registered": "Дата регистрации",
|
||||
"challenge.admin.users.table.actions": "Действия",
|
||||
@@ -206,6 +210,7 @@
|
||||
"challenge.admin.submissions.search.empty.title": "Ничего не найдено",
|
||||
"challenge.admin.submissions.search.empty.description": "Попробуйте изменить фильтры",
|
||||
"challenge.admin.submissions.table.user": "Пользователь",
|
||||
"challenge.admin.submissions.table.workplace": "Место",
|
||||
"challenge.admin.submissions.table.task": "Задание",
|
||||
"challenge.admin.submissions.table.status": "Статус",
|
||||
"challenge.admin.submissions.table.attempt": "Попытка",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "challenge-admin-pl",
|
||||
"version": "1.1.0",
|
||||
"version": "1.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "challenge-admin-pl",
|
||||
"version": "1.1.0",
|
||||
"version": "1.5.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@brojs/cli": "^1.9.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "challenge-admin",
|
||||
"version": "1.1.0",
|
||||
"version": "1.5.2",
|
||||
"description": "",
|
||||
"main": "./src/index.tsx",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,6 +3,6 @@ import Keycloak from 'keycloak-js'
|
||||
export const keycloak = new Keycloak({
|
||||
url: KC_URL,
|
||||
realm: KC_REALM,
|
||||
clientId: KC_CLIENT_ID,
|
||||
});
|
||||
clientId: KC_CLIENT_ID
|
||||
})
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ export const ParticipantsProgress: React.FC<ParticipantsProgressProps> = ({ part
|
||||
<VStack align="stretch" gap={3}>
|
||||
{/* Participant Header */}
|
||||
<Box>
|
||||
{participant.workplaceNumber && (
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
{participant.workplaceNumber}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="lg" fontWeight="bold" color="teal.700">
|
||||
{participant.nickname}
|
||||
</Text>
|
||||
|
||||
@@ -41,9 +41,18 @@ export const SubmissionsPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { chainId } = useParams<{ chainId?: string }>()
|
||||
|
||||
// Проверяем feature flag
|
||||
// Проверяем feature flags
|
||||
const featureValue = getFeatureValue('challenge-admin', 'use-chain-submissions-api')
|
||||
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)
|
||||
@@ -70,7 +79,10 @@ export const SubmissionsPage: React.FC = () => {
|
||||
userId: selectedUserId || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
},
|
||||
{ skip: !chainId || !useNewApi }
|
||||
{
|
||||
skip: !chainId || !useNewApi,
|
||||
pollingInterval: pollingIntervalMs,
|
||||
}
|
||||
)
|
||||
|
||||
// Старый API: получаем общую статистику и submissions отдельно
|
||||
@@ -470,9 +482,16 @@ export const SubmissionsPage: React.FC = () => {
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between" mb={2} gap={2}>
|
||||
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px">
|
||||
{participant.nickname}
|
||||
</Text>
|
||||
<VStack align="start" gap={0}>
|
||||
{participant.workplaceNumber && (
|
||||
<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">
|
||||
{participant.progressPercent}%
|
||||
</Badge>
|
||||
@@ -508,6 +527,7 @@ export const SubmissionsPage: React.FC = () => {
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<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.status')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.submissions.table.attempt')}</Table.ColumnHeader>
|
||||
@@ -530,6 +550,11 @@ export const SubmissionsPage: React.FC = () => {
|
||||
? rawUser
|
||||
: ''
|
||||
|
||||
const workplaceNumber =
|
||||
rawUser && typeof rawUser === 'object' && 'workplaceNumber' in rawUser
|
||||
? rawUser.workplaceNumber ?? ''
|
||||
: ''
|
||||
|
||||
const title =
|
||||
rawTask && typeof rawTask === 'object' && 'title' in rawTask
|
||||
? (rawTask.title ?? '')
|
||||
@@ -540,6 +565,11 @@ export const SubmissionsPage: React.FC = () => {
|
||||
return (
|
||||
<Table.Row key={submission.id}>
|
||||
<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>
|
||||
<StatusBadge status={submission.status} />
|
||||
|
||||
@@ -26,6 +26,20 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||
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 = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
@@ -40,6 +54,7 @@ export const TaskFormPage: React.FC = () => {
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [learningMaterial, setLearningMaterial] = useState('')
|
||||
const [hiddenInstructions, setHiddenInstructions] = useState('')
|
||||
const [showDescPreview, setShowDescPreview] = useState(false)
|
||||
const [testAnswer, setTestAnswer] = useState('')
|
||||
@@ -50,6 +65,7 @@ export const TaskFormPage: React.FC = () => {
|
||||
if (task) {
|
||||
setTitle(task.title)
|
||||
setDescription(task.description)
|
||||
setLearningMaterial(task.learningMaterial || '')
|
||||
setHiddenInstructions(task.hiddenInstructions || '')
|
||||
}
|
||||
}, [task])
|
||||
@@ -106,6 +122,7 @@ export const TaskFormPage: React.FC = () => {
|
||||
data: {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
learningMaterial: learningMaterial.trim() || undefined,
|
||||
hiddenInstructions: hiddenInstructions.trim() || undefined,
|
||||
},
|
||||
}).unwrap()
|
||||
@@ -118,6 +135,7 @@ export const TaskFormPage: React.FC = () => {
|
||||
await createTask({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
learningMaterial: learningMaterial.trim() || undefined,
|
||||
hiddenInstructions: hiddenInstructions.trim() || undefined,
|
||||
}).unwrap()
|
||||
toaster.create({
|
||||
@@ -126,7 +144,7 @@ export const TaskFormPage: React.FC = () => {
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
navigate(URLs.tasks)
|
||||
// navigate(URLs.tasks)
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
(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.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 */}
|
||||
<Field.Root>
|
||||
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">
|
||||
|
||||
@@ -61,6 +61,7 @@ export const UsersPage: React.FC = () => {
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<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.stats.total.submissions')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.users.stats.completed')}</Table.ColumnHeader>
|
||||
@@ -71,6 +72,11 @@ export const UsersPage: React.FC = () => {
|
||||
{filteredUsers.map((user) => (
|
||||
<Table.Row key={user.userId}>
|
||||
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{user.workplaceNumber || '—'}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text fontSize="xs" fontFamily="monospace" color="gray.600">
|
||||
{user.userId}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface ChallengeUser {
|
||||
_id: string
|
||||
id: string
|
||||
nickname: string
|
||||
workplaceNumber?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -12,6 +13,7 @@ export interface ChallengeTask {
|
||||
id: string
|
||||
title: string
|
||||
description: string // Markdown
|
||||
learningMaterial?: string // Дополнительный учебный материал в Markdown
|
||||
hiddenInstructions?: string // Только для преподавателей
|
||||
creator?: {
|
||||
sub: string
|
||||
@@ -121,12 +123,14 @@ export interface APIResponse<T> {
|
||||
export interface CreateTaskRequest {
|
||||
title: string
|
||||
description: string
|
||||
learningMaterial?: string
|
||||
hiddenInstructions?: string
|
||||
}
|
||||
|
||||
export interface UpdateTaskRequest {
|
||||
title?: string
|
||||
description?: string
|
||||
learningMaterial?: string
|
||||
hiddenInstructions?: string
|
||||
}
|
||||
|
||||
@@ -177,6 +181,7 @@ export interface ChainProgress {
|
||||
export interface ActiveParticipant {
|
||||
userId: string
|
||||
nickname: string
|
||||
workplaceNumber?: string
|
||||
totalSubmissions: number
|
||||
completedTasks: number
|
||||
chainProgress: ChainProgress[]
|
||||
@@ -191,6 +196,7 @@ export interface TaskProgress {
|
||||
export interface ParticipantProgress {
|
||||
userId: string
|
||||
nickname: string
|
||||
workplaceNumber?: string
|
||||
taskProgress: TaskProgress[]
|
||||
completedCount: number
|
||||
progressPercent: number
|
||||
@@ -259,6 +265,7 @@ export interface TestSubmissionResult {
|
||||
export interface ChainSubmissionsParticipant {
|
||||
userId: string
|
||||
nickname: string
|
||||
workplaceNumber?: string
|
||||
completedTasks: number
|
||||
totalTasks: number
|
||||
progressPercent: number
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
{
|
||||
"userId": "6909b51512c75d75a36a52bf",
|
||||
"nickname": "Примаков А.А.",
|
||||
"workplaceNumber": "PC-07",
|
||||
"totalSubmissions": 14,
|
||||
"completedTasks": 1,
|
||||
"chainProgress": [
|
||||
@@ -225,6 +226,7 @@
|
||||
{
|
||||
"userId": "user_1",
|
||||
"nickname": "alex_dev",
|
||||
"workplaceNumber": "PC-01",
|
||||
"totalSubmissions": 18,
|
||||
"completedTasks": 12,
|
||||
"chainProgress": [
|
||||
@@ -247,6 +249,7 @@
|
||||
{
|
||||
"userId": "user_2",
|
||||
"nickname": "maria_coder",
|
||||
"workplaceNumber": "PC-05",
|
||||
"totalSubmissions": 15,
|
||||
"completedTasks": 9,
|
||||
"chainProgress": [
|
||||
@@ -269,6 +272,7 @@
|
||||
{
|
||||
"userId": "user_3",
|
||||
"nickname": "ivan_programmer",
|
||||
"workplaceNumber": "PC-12",
|
||||
"totalSubmissions": 10,
|
||||
"completedTasks": 5,
|
||||
"chainProgress": [
|
||||
@@ -291,6 +295,7 @@
|
||||
{
|
||||
"userId": "user_4",
|
||||
"nickname": "kate_fullstack",
|
||||
"workplaceNumber": "PC-03",
|
||||
"totalSubmissions": 22,
|
||||
"completedTasks": 15,
|
||||
"chainProgress": [
|
||||
@@ -313,6 +318,7 @@
|
||||
{
|
||||
"userId": "user_5",
|
||||
"nickname": "dmitry_backend",
|
||||
"workplaceNumber": "PC-15",
|
||||
"totalSubmissions": 12,
|
||||
"completedTasks": 6,
|
||||
"chainProgress": [
|
||||
@@ -335,6 +341,7 @@
|
||||
{
|
||||
"userId": "user_6",
|
||||
"nickname": "anna_react",
|
||||
"workplaceNumber": "PC-08",
|
||||
"totalSubmissions": 14,
|
||||
"completedTasks": 7,
|
||||
"chainProgress": [
|
||||
@@ -376,6 +383,7 @@
|
||||
{
|
||||
"userId": "user_1",
|
||||
"nickname": "alex_dev",
|
||||
"workplaceNumber": "PC-01",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||
@@ -394,6 +402,7 @@
|
||||
{
|
||||
"userId": "user_2",
|
||||
"nickname": "maria_coder",
|
||||
"workplaceNumber": "PC-05",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||
@@ -412,6 +421,7 @@
|
||||
{
|
||||
"userId": "user_3",
|
||||
"nickname": "ivan_programmer",
|
||||
"workplaceNumber": "PC-12",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||
@@ -430,6 +440,7 @@
|
||||
{
|
||||
"userId": "user_4",
|
||||
"nickname": "kate_fullstack",
|
||||
"workplaceNumber": "PC-03",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||
@@ -448,6 +459,7 @@
|
||||
{
|
||||
"userId": "user_5",
|
||||
"nickname": "dmitry_backend",
|
||||
"workplaceNumber": "PC-15",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
|
||||
@@ -466,6 +478,7 @@
|
||||
{
|
||||
"userId": "user_6",
|
||||
"nickname": "anna_react",
|
||||
"workplaceNumber": "PC-08",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
|
||||
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "pending" },
|
||||
@@ -503,6 +516,7 @@
|
||||
{
|
||||
"userId": "user_1",
|
||||
"nickname": "alex_dev",
|
||||
"workplaceNumber": "PC-01",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||
@@ -521,6 +535,7 @@
|
||||
{
|
||||
"userId": "user_2",
|
||||
"nickname": "maria_coder",
|
||||
"workplaceNumber": "PC-05",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||
@@ -539,6 +554,7 @@
|
||||
{
|
||||
"userId": "user_3",
|
||||
"nickname": "ivan_programmer",
|
||||
"workplaceNumber": "PC-12",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||
@@ -557,6 +573,7 @@
|
||||
{
|
||||
"userId": "user_4",
|
||||
"nickname": "kate_fullstack",
|
||||
"workplaceNumber": "PC-03",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||
@@ -575,6 +592,7 @@
|
||||
{
|
||||
"userId": "user_5",
|
||||
"nickname": "dmitry_backend",
|
||||
"workplaceNumber": "PC-15",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "pending" },
|
||||
@@ -593,6 +611,7 @@
|
||||
{
|
||||
"userId": "user_6",
|
||||
"nickname": "anna_react",
|
||||
"workplaceNumber": "PC-08",
|
||||
"taskProgress": [
|
||||
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
|
||||
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"_id": "user001",
|
||||
"id": "user001",
|
||||
"nickname": "alex_student",
|
||||
"workplaceNumber": "PC-01",
|
||||
"createdAt": "2024-10-15T08:30:00.000Z"
|
||||
},
|
||||
"task": {
|
||||
@@ -31,6 +32,7 @@
|
||||
"_id": "user001",
|
||||
"id": "user001",
|
||||
"nickname": "alex_student",
|
||||
"workplaceNumber": "PC-01",
|
||||
"createdAt": "2024-10-15T08:30:00.000Z"
|
||||
},
|
||||
"task": {
|
||||
@@ -56,6 +58,7 @@
|
||||
"_id": "user002",
|
||||
"id": "user002",
|
||||
"nickname": "maria_dev",
|
||||
"workplaceNumber": "PC-05",
|
||||
"createdAt": "2024-10-16T10:15:00.000Z"
|
||||
},
|
||||
"task": {
|
||||
@@ -81,6 +84,7 @@
|
||||
"_id": "user003",
|
||||
"id": "user003",
|
||||
"nickname": "ivan_coder",
|
||||
"workplaceNumber": "PC-12",
|
||||
"createdAt": "2024-10-17T14:20:00.000Z"
|
||||
},
|
||||
"task": {
|
||||
@@ -106,6 +110,7 @@
|
||||
"_id": "user004",
|
||||
"id": "user004",
|
||||
"nickname": "olga_js",
|
||||
"workplaceNumber": "PC-03",
|
||||
"createdAt": "2024-10-18T09:00:00.000Z"
|
||||
},
|
||||
"task": {
|
||||
@@ -131,6 +136,7 @@
|
||||
"_id": "user005",
|
||||
"id": "user005",
|
||||
"nickname": "dmitry_react",
|
||||
"workplaceNumber": "PC-15",
|
||||
"createdAt": "2024-10-20T11:45:00.000Z"
|
||||
},
|
||||
"task": {
|
||||
@@ -156,6 +162,7 @@
|
||||
"_id": "user006",
|
||||
"id": "user006",
|
||||
"nickname": "anna_frontend",
|
||||
"workplaceNumber": "PC-08",
|
||||
"createdAt": "2024-10-22T16:30:00.000Z"
|
||||
},
|
||||
"task": {
|
||||
|
||||
@@ -3,36 +3,42 @@
|
||||
"_id": "user001",
|
||||
"id": "user001",
|
||||
"nickname": "alex_student",
|
||||
"workplaceNumber": "PC-01",
|
||||
"createdAt": "2024-10-15T08:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"_id": "user002",
|
||||
"id": "user002",
|
||||
"nickname": "maria_dev",
|
||||
"workplaceNumber": "PC-05",
|
||||
"createdAt": "2024-10-16T10:15:00.000Z"
|
||||
},
|
||||
{
|
||||
"_id": "user003",
|
||||
"id": "user003",
|
||||
"nickname": "ivan_coder",
|
||||
"workplaceNumber": "PC-12",
|
||||
"createdAt": "2024-10-17T14:20:00.000Z"
|
||||
},
|
||||
{
|
||||
"_id": "user004",
|
||||
"id": "user004",
|
||||
"nickname": "olga_js",
|
||||
"workplaceNumber": "PC-03",
|
||||
"createdAt": "2024-10-18T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"_id": "user005",
|
||||
"id": "user005",
|
||||
"nickname": "dmitry_react",
|
||||
"workplaceNumber": "PC-15",
|
||||
"createdAt": "2024-10-20T11:45:00.000Z"
|
||||
},
|
||||
{
|
||||
"_id": "user006",
|
||||
"id": "user006",
|
||||
"nickname": "anna_frontend",
|
||||
"workplaceNumber": "PC-08",
|
||||
"createdAt": "2024-10-22T16:30:00.000Z"
|
||||
},
|
||||
{
|
||||
@@ -45,6 +51,7 @@
|
||||
"_id": "user008",
|
||||
"id": "user008",
|
||||
"nickname": "elena_fullstack",
|
||||
"workplaceNumber": "PC-20",
|
||||
"createdAt": "2024-10-28T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -604,18 +604,24 @@ router.get('/challenge/chain/:chainId/submissions', (req, res) => {
|
||||
filteredSubmissions.forEach(sub => {
|
||||
const subUserId = typeof sub.user === 'object' ? sub.user.id : sub.user;
|
||||
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;
|
||||
if (!nickname) {
|
||||
let workplaceNumber = subUserWorkplaceNumber;
|
||||
if (!nickname || !workplaceNumber) {
|
||||
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)) {
|
||||
participantMap.set(subUserId, {
|
||||
userId: subUserId,
|
||||
nickname: nickname,
|
||||
workplaceNumber: workplaceNumber,
|
||||
completedTasks: new Set(),
|
||||
totalTasks: chain.tasks.length,
|
||||
});
|
||||
@@ -632,6 +638,7 @@ router.get('/challenge/chain/:chainId/submissions', (req, res) => {
|
||||
const participants = Array.from(participantMap.values()).map(p => ({
|
||||
userId: p.userId,
|
||||
nickname: p.nickname,
|
||||
workplaceNumber: p.workplaceNumber,
|
||||
completedTasks: p.completedTasks.size,
|
||||
totalTasks: p.totalTasks,
|
||||
progressPercent: p.totalTasks > 0
|
||||
|
||||
Reference in New Issue
Block a user