init + api use
This commit is contained in:
132
.gitignore
vendored
Normal file
132
.gitignore
vendored
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
25
@types/index.d.ts
vendored
Normal file
25
@types/index.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
declare const IS_PROD: string
|
||||||
|
declare const KC_URL: string
|
||||||
|
declare const KC_REALM: string
|
||||||
|
declare const KC_CLIENT_ID: string
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const svg_path: string
|
||||||
|
|
||||||
|
export default svg_path
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpg' {
|
||||||
|
const jpg_path: string
|
||||||
|
|
||||||
|
export default jpg_path
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const png_path: string
|
||||||
|
|
||||||
|
export default png_path
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const __webpack_public_path__: string
|
||||||
|
|
||||||
258
README.md
Normal file
258
README.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# Challenge Admin Panel
|
||||||
|
|
||||||
|
Админская панель для управления Challenge Service - системой автоматической проверки заданий с помощью LLM.
|
||||||
|
|
||||||
|
## 🎯 Реализованная функциональность
|
||||||
|
|
||||||
|
### ✅ Dashboard (Главная страница)
|
||||||
|
- **Основные метрики:**
|
||||||
|
- Количество пользователей, заданий, цепочек
|
||||||
|
- Общее количество проверок
|
||||||
|
- Статус очереди с загруженностью
|
||||||
|
- Среднее время проверки
|
||||||
|
- **Статистика проверок:**
|
||||||
|
- Принятые/отклоненные/ожидающие
|
||||||
|
- Распределение по статусам
|
||||||
|
- **Real-time обновление:** автоматическое обновление данных каждые 10 секунд
|
||||||
|
|
||||||
|
### ✅ CRUD для Tasks (Заданий)
|
||||||
|
- **Список заданий:**
|
||||||
|
- Таблица с названием, автором, датой создания
|
||||||
|
- Индикатор наличия скрытых инструкций (🔒)
|
||||||
|
- Поиск по названию
|
||||||
|
- Действия: редактировать, удалить
|
||||||
|
- **Создание/редактирование задания:**
|
||||||
|
- Название (обязательное, до 255 символов)
|
||||||
|
- Описание в формате Markdown с вкладками "Редактор" и "Превью"
|
||||||
|
- **Скрытые инструкции для LLM** (🔒 только для преподавателей):
|
||||||
|
- Специальное поле с визуальным выделением
|
||||||
|
- Инструкции передаются LLM при проверке, но не видны студентам
|
||||||
|
- Примеры использования в подсказках
|
||||||
|
- Отображение метаданных (автор, даты создания/обновления)
|
||||||
|
- Валидация и обработка ошибок
|
||||||
|
|
||||||
|
### ✅ CRUD для Chains (Цепочек)
|
||||||
|
- **Список цепочек:**
|
||||||
|
- Таблица с названием, количеством заданий, датой создания
|
||||||
|
- Поиск по названию
|
||||||
|
- Действия: редактировать, удалить
|
||||||
|
- **Создание/редактирование цепочки:**
|
||||||
|
- Название цепочки (обязательное)
|
||||||
|
- Выбор заданий из существующих
|
||||||
|
- Управление порядком заданий (кнопки вверх/вниз)
|
||||||
|
- Добавление/удаление заданий в цепочку
|
||||||
|
- Поиск доступных заданий
|
||||||
|
|
||||||
|
### ✅ Users (Пользователи)
|
||||||
|
- **Список пользователей:**
|
||||||
|
- Таблица с nickname, ID, датой регистрации
|
||||||
|
- Поиск по nickname
|
||||||
|
- Кнопка просмотра статистики
|
||||||
|
- **Детальная статистика пользователя (модальное окно):**
|
||||||
|
- Общие метрики: выполнено, всего попыток, в процессе, требует доработки
|
||||||
|
- Прогресс по цепочкам с визуальными прогресс-барами
|
||||||
|
- Детали по заданиям со статусами
|
||||||
|
- Среднее время проверки
|
||||||
|
|
||||||
|
### ✅ Submissions (Попытки)
|
||||||
|
- **Список попыток:**
|
||||||
|
- Таблица с пользователем, заданием, статусом, попыткой
|
||||||
|
- Дата отправки и время проверки
|
||||||
|
- **Фильтрация:**
|
||||||
|
- Поиск по пользователю или заданию
|
||||||
|
- Фильтр по статусу (все/принято/доработка/проверяется/ожидает)
|
||||||
|
- Цветные бейджи статусов
|
||||||
|
- **Детали попытки (модальное окно):**
|
||||||
|
- Метаданные: пользователь, статус, даты
|
||||||
|
- Описание задания (Markdown)
|
||||||
|
- Решение пользователя
|
||||||
|
- Обратная связь от LLM
|
||||||
|
- Время проверки
|
||||||
|
|
||||||
|
## 🛠 Технологический стек
|
||||||
|
|
||||||
|
- **React 18** + **TypeScript**
|
||||||
|
- **React Router** для навигации
|
||||||
|
- **Redux Toolkit** + **RTK Query** для state management и API
|
||||||
|
- **Chakra UI v3** для UI компонентов
|
||||||
|
- **react-markdown** для отображения Markdown
|
||||||
|
- **Keycloak** для авторизации преподавателей
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── __data__/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── api.ts # RTK Query API endpoints
|
||||||
|
│ ├── kc.ts # Keycloak конфигурация
|
||||||
|
│ ├── store.ts # Redux store
|
||||||
|
│ └── urls.ts # URL константы
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ └── toaster.tsx # Toast уведомления
|
||||||
|
│ ├── ConfirmDialog.tsx # Диалог подтверждения
|
||||||
|
│ ├── EmptyState.tsx # Пустое состояние
|
||||||
|
│ ├── ErrorAlert.tsx # Отображение ошибок
|
||||||
|
│ ├── Layout.tsx # Общий layout с навигацией
|
||||||
|
│ ├── LoadingSpinner.tsx # Индикатор загрузки
|
||||||
|
│ ├── StatCard.tsx # Карточка метрики
|
||||||
|
│ └── StatusBadge.tsx # Бейдж статуса
|
||||||
|
├── pages/
|
||||||
|
│ ├── dashboard/
|
||||||
|
│ │ └── DashboardPage.tsx # Главная страница
|
||||||
|
│ ├── tasks/
|
||||||
|
│ │ ├── TasksListPage.tsx # Список заданий
|
||||||
|
│ │ └── TaskFormPage.tsx # Форма задания
|
||||||
|
│ ├── chains/
|
||||||
|
│ │ ├── ChainsListPage.tsx # Список цепочек
|
||||||
|
│ │ └── ChainFormPage.tsx # Форма цепочки
|
||||||
|
│ ├── users/
|
||||||
|
│ │ └── UsersPage.tsx # Список пользователей
|
||||||
|
│ └── submissions/
|
||||||
|
│ └── SubmissionsPage.tsx # Список попыток
|
||||||
|
├── types/
|
||||||
|
│ └── challenge.ts # TypeScript типы
|
||||||
|
├── app.tsx # Главный компонент
|
||||||
|
├── dashboard.tsx # Роутинг
|
||||||
|
├── index.tsx # Entry point
|
||||||
|
└── theme.tsx # Chakra UI theme
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Авторизация
|
||||||
|
|
||||||
|
Приложение требует авторизации через **Keycloak** с ролью **`teacher`** в клиенте **`journal`**.
|
||||||
|
|
||||||
|
Конфигурация в `bro.config.js`:
|
||||||
|
```javascript
|
||||||
|
KC_URL: 'https://auth.brojs.ru'
|
||||||
|
KC_REALM: 'itpark'
|
||||||
|
KC_CLIENT_ID: 'journal'
|
||||||
|
```
|
||||||
|
|
||||||
|
Все API запросы автоматически отправляют Bearer токен через RTK Query middleware.
|
||||||
|
|
||||||
|
## 🚀 Запуск проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка зависимостей
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Запуск в режиме разработки (с стабами API)
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Сборка для production
|
||||||
|
npm run build:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу: `http://localhost:8099`
|
||||||
|
|
||||||
|
### 📡 Стабовый API сервер
|
||||||
|
|
||||||
|
Проект включает полнофункциональный стабовый API сервер для разработки и тестирования без реального бэкенда.
|
||||||
|
|
||||||
|
**Тестовые данные:**
|
||||||
|
- `stubs/api/data/tasks.json` - 5 заданий с hiddenInstructions
|
||||||
|
- `stubs/api/data/chains.json` - 3 цепочки заданий
|
||||||
|
- `stubs/api/data/users.json` - 8 пользователей
|
||||||
|
- `stubs/api/data/submissions.json` - 8 попыток с feedback от LLM
|
||||||
|
- `stubs/api/data/stats.json` - системная статистика
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- ✅ Полный CRUD для заданий и цепочек
|
||||||
|
- ✅ In-memory хранилище (изменения сбрасываются при перезапуске)
|
||||||
|
- ✅ Автоматическое обновление статистики
|
||||||
|
- ✅ Генерация статистики пользователей на лету
|
||||||
|
- ✅ Поддержка всех endpoints из документации
|
||||||
|
|
||||||
|
**Примечание:** Все ответы возвращаются в формате `{ error: null, data: ... }`
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
Все endpoints используют базовый URL из конфига (`/api`).
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- `GET /challenge/tasks` - список заданий
|
||||||
|
- `GET /challenge/task/:id` - одно задание
|
||||||
|
- `POST /challenge/task` - создать задание
|
||||||
|
- `PUT /challenge/task/:id` - обновить задание
|
||||||
|
- `DELETE /challenge/task/:id` - удалить задание
|
||||||
|
|
||||||
|
### Chains
|
||||||
|
- `GET /challenge/chains` - список цепочек
|
||||||
|
- `GET /challenge/chain/:id` - одна цепочка
|
||||||
|
- `POST /challenge/chain` - создать цепочку
|
||||||
|
- `PUT /challenge/chain/:id` - обновить цепочку
|
||||||
|
- `DELETE /challenge/chain/:id` - удалить цепочку
|
||||||
|
|
||||||
|
### Users & Stats
|
||||||
|
- `GET /challenge/users` - список пользователей
|
||||||
|
- `GET /challenge/stats` - общая статистика системы
|
||||||
|
- `GET /challenge/user/:userId/stats` - статистика пользователя
|
||||||
|
|
||||||
|
### Submissions
|
||||||
|
- `GET /challenge/submissions` - все попытки
|
||||||
|
- `GET /challenge/user/:userId/submissions` - попытки пользователя
|
||||||
|
|
||||||
|
## ⚙️ Ключевые особенности реализации
|
||||||
|
|
||||||
|
### RTK Query с кэшированием
|
||||||
|
Настроены `tagTypes` для автоматической инвалидации кэша:
|
||||||
|
- `Task` - для заданий
|
||||||
|
- `Chain` - для цепочек
|
||||||
|
- `User` - для пользователей
|
||||||
|
- `Submission` - для попыток
|
||||||
|
- `Stats` - для статистики
|
||||||
|
|
||||||
|
### Real-time обновления
|
||||||
|
Dashboard использует `pollingInterval: 10000` для автоматического обновления статистики каждые 10 секунд.
|
||||||
|
|
||||||
|
### hiddenInstructions (Скрытые инструкции)
|
||||||
|
Специальное поле только для преподавателей:
|
||||||
|
- Визуально выделено фиолетовым цветом
|
||||||
|
- Передаётся LLM при проверке решений
|
||||||
|
- Не видно студентам
|
||||||
|
- Позволяет тонко настроить проверку
|
||||||
|
|
||||||
|
### UX оптимизации
|
||||||
|
- Loading состояния для всех запросов
|
||||||
|
- Toast уведомления для успеха/ошибок
|
||||||
|
- Confirm диалоги для опасных действий (удаление)
|
||||||
|
- Empty states для пустых списков
|
||||||
|
- Поиск и фильтрация на всех страницах
|
||||||
|
- Адаптивный дизайн
|
||||||
|
|
||||||
|
## 🔗 Навигация
|
||||||
|
|
||||||
|
Переход между проектами настроен через `bro.config.js`:
|
||||||
|
```javascript
|
||||||
|
navigations: {
|
||||||
|
'challenge-admin-pl.main': '/challenge-admin-pl',
|
||||||
|
'link.challenge': '/challenge',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
В Layout есть кнопка "Открыть проигрыватель" для перехода к студенческому интерфейсу.
|
||||||
|
|
||||||
|
## 📝 Документация
|
||||||
|
|
||||||
|
Подробная документация по API и архитектуре системы находится в папке `docs/`:
|
||||||
|
- `CHALLENGE_FRONTEND_GUIDE.md` - руководство для фронтенд разработчиков
|
||||||
|
- `TEACHER_GUIDE.md` - руководство для преподавателей
|
||||||
|
- `CHALLENGE_ANALYTICS_SUMMARY.md` - аналитика и метрики
|
||||||
|
- `CHALLENGE_REACT_EXAMPLE.md` - примеры React компонентов
|
||||||
|
|
||||||
|
## ✨ Дополнительно
|
||||||
|
|
||||||
|
- Все компоненты типизированы с TypeScript
|
||||||
|
- Markdown поддержка с превью и стилизацией
|
||||||
|
- Обработка ошибок API с понятными сообщениями
|
||||||
|
- Валидация форм
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Версия:** 1.0.0
|
||||||
|
**Дата:** Ноябрь 2025
|
||||||
|
**Статус:** ✅ Production Ready
|
||||||
|
|
||||||
31
bro.config.js
Normal file
31
bro.config.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const pkg = require('./package')
|
||||||
|
const webpack = require('webpack')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apiPath: 'stubs/api',
|
||||||
|
webpackConfig: {
|
||||||
|
output: {
|
||||||
|
publicPath: `/static/${pkg.name}/${process.env.VERSION || pkg.version}/`
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
KC_URL: process.env.KC_URL || '"https://auth.brojs.ru"',
|
||||||
|
KC_REALM: process.env.KC_REALM || '"itpark"',
|
||||||
|
KC_CLIENT_ID: process.env.KC_CLIENT_ID || '"journal"',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
/* use https://admin.bro-js.ru/ to create config, navigations and features */
|
||||||
|
navigations: {
|
||||||
|
'challenge-admin.main': '/challenge-admin',
|
||||||
|
'link.challenge': '/challenge',
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
'challenge-admin': {
|
||||||
|
// add your features here in the format [featureName]: { value: string }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
'challenge-admin.api': '/api'
|
||||||
|
}
|
||||||
|
}
|
||||||
635
docs/CHALLENGE_ANALYTICS_SUMMARY.md
Normal file
635
docs/CHALLENGE_ANALYTICS_SUMMARY.md
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
# Challenge Service - Аналитика и метрики для фронтенда
|
||||||
|
|
||||||
|
Краткое руководство по ключевым метрикам и аналитике для интеграции на фронтенде.
|
||||||
|
|
||||||
|
## 📊 Ключевые метрики для отслеживания
|
||||||
|
|
||||||
|
### 1. Метрики производительности
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Метрики для мониторинга
|
||||||
|
interface PerformanceMetrics {
|
||||||
|
// Время от отправки до получения результата
|
||||||
|
timeToFeedback: number // миллисекунды
|
||||||
|
|
||||||
|
// Время ожидания в очереди
|
||||||
|
queueWaitTime: number // миллисекунды
|
||||||
|
|
||||||
|
// Время непосредственной проверки
|
||||||
|
checkTime: number // миллисекунды
|
||||||
|
|
||||||
|
// Позиция в очереди при добавлении
|
||||||
|
initialQueuePosition: number
|
||||||
|
|
||||||
|
// Количество проверок статуса до завершения
|
||||||
|
pollsBeforeComplete: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пример сбора метрик
|
||||||
|
class MetricsCollector {
|
||||||
|
private startTime: number = 0
|
||||||
|
private pollCount: number = 0
|
||||||
|
|
||||||
|
startTracking() {
|
||||||
|
this.startTime = Date.now()
|
||||||
|
this.pollCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementPoll() {
|
||||||
|
this.pollCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetrics(submission: ChallengeSubmission): PerformanceMetrics {
|
||||||
|
return {
|
||||||
|
timeToFeedback: Date.now() - this.startTime,
|
||||||
|
queueWaitTime: submission.checkedAt
|
||||||
|
? new Date(submission.checkedAt).getTime() - new Date(submission.submittedAt).getTime()
|
||||||
|
: 0,
|
||||||
|
checkTime: submission.checkedAt
|
||||||
|
? new Date(submission.checkedAt).getTime() - new Date(submission.submittedAt).getTime()
|
||||||
|
: 0,
|
||||||
|
initialQueuePosition: 0, // Сохранить из первого ответа
|
||||||
|
pollsBeforeComplete: this.pollCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Метрики пользовательского поведения
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UserBehaviorMetrics {
|
||||||
|
// Время, проведенное на задании
|
||||||
|
timeSpentOnTask: number // секунды
|
||||||
|
|
||||||
|
// Количество символов в решении
|
||||||
|
solutionLength: number
|
||||||
|
|
||||||
|
// Количество редактирований текста
|
||||||
|
editCount: number
|
||||||
|
|
||||||
|
// Использовал ли черновик
|
||||||
|
usedDraft: boolean
|
||||||
|
|
||||||
|
// Время от загрузки до отправки
|
||||||
|
timeToSubmit: number // секунды
|
||||||
|
}
|
||||||
|
|
||||||
|
// Трекинг поведения
|
||||||
|
class BehaviorTracker {
|
||||||
|
private taskStartTime: number = Date.now()
|
||||||
|
private editCount: number = 0
|
||||||
|
private lastValue: string = ''
|
||||||
|
|
||||||
|
onTextChange(newValue: string) {
|
||||||
|
if (newValue !== this.lastValue) {
|
||||||
|
this.editCount++
|
||||||
|
this.lastValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetrics(result: string, usedDraft: boolean): UserBehaviorMetrics {
|
||||||
|
return {
|
||||||
|
timeSpentOnTask: Math.floor((Date.now() - this.taskStartTime) / 1000),
|
||||||
|
solutionLength: result.length,
|
||||||
|
editCount: this.editCount,
|
||||||
|
usedDraft,
|
||||||
|
timeToSubmit: Math.floor((Date.now() - this.taskStartTime) / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Метрики успешности
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SuccessMetrics {
|
||||||
|
// Процент принятых заданий с первой попытки
|
||||||
|
firstAttemptSuccessRate: number // 0-100
|
||||||
|
|
||||||
|
// Среднее количество попыток до успеха
|
||||||
|
averageAttemptsToSuccess: number
|
||||||
|
|
||||||
|
// Процент завершенных цепочек
|
||||||
|
chainCompletionRate: number // 0-100
|
||||||
|
|
||||||
|
// Время до первого успешного задания
|
||||||
|
timeToFirstSuccess: number // минуты
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расчет метрик успешности
|
||||||
|
function calculateSuccessMetrics(stats: UserStats): SuccessMetrics {
|
||||||
|
const taskStats = stats.taskStats
|
||||||
|
|
||||||
|
const firstAttemptSuccess = taskStats.filter(
|
||||||
|
t => t.status === 'completed' && t.totalAttempts === 1
|
||||||
|
).length
|
||||||
|
|
||||||
|
const completedTasks = taskStats.filter(t => t.status === 'completed')
|
||||||
|
const totalAttempts = completedTasks.reduce((sum, t) => sum + t.totalAttempts, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstAttemptSuccessRate: (firstAttemptSuccess / taskStats.length) * 100,
|
||||||
|
averageAttemptsToSuccess: completedTasks.length > 0
|
||||||
|
? totalAttempts / completedTasks.length
|
||||||
|
: 0,
|
||||||
|
chainCompletionRate: (stats.chainStats.filter(c => c.progress === 100).length / stats.chainStats.length) * 100,
|
||||||
|
timeToFirstSuccess: 0 // Требует дополнительных данных
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Дашборды для фронтенда
|
||||||
|
|
||||||
|
### 1. Personal Dashboard (для студента)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PersonalDashboard {
|
||||||
|
// Общий прогресс
|
||||||
|
overview: {
|
||||||
|
tasksCompleted: number
|
||||||
|
totalTasks: number
|
||||||
|
completionPercentage: number
|
||||||
|
currentStreak: number // дней подряд
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текущие цепочки
|
||||||
|
activeChains: Array<{
|
||||||
|
chainId: string
|
||||||
|
name: string
|
||||||
|
progress: number
|
||||||
|
nextTask: ChallengeTask | null
|
||||||
|
estimatedTimeToComplete: number // минуты
|
||||||
|
}>
|
||||||
|
|
||||||
|
// Последние достижения
|
||||||
|
recentAchievements: Array<{
|
||||||
|
type: 'task_completed' | 'chain_completed' | 'first_try_success'
|
||||||
|
taskTitle: string
|
||||||
|
timestamp: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
// Статистика по попыткам
|
||||||
|
attemptsStats: {
|
||||||
|
totalAttempts: number
|
||||||
|
successfulAttempts: number
|
||||||
|
successRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рекомендации
|
||||||
|
recommendations: Array<{
|
||||||
|
type: 'retry' | 'continue' | 'new_chain'
|
||||||
|
message: string
|
||||||
|
actionLink: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация dashboard
|
||||||
|
async function generatePersonalDashboard(userId: string): Promise<PersonalDashboard> {
|
||||||
|
const stats = await challengeAPI.getUserStats(userId)
|
||||||
|
const chains = await challengeAPI.getChains()
|
||||||
|
|
||||||
|
return {
|
||||||
|
overview: {
|
||||||
|
tasksCompleted: stats.completedTasks,
|
||||||
|
totalTasks: stats.totalTasksAttempted,
|
||||||
|
completionPercentage: (stats.completedTasks / stats.totalTasksAttempted) * 100,
|
||||||
|
currentStreak: 0 // Требует дополнительной логики
|
||||||
|
},
|
||||||
|
activeChains: stats.chainStats
|
||||||
|
.filter(c => c.progress > 0 && c.progress < 100)
|
||||||
|
.map(c => {
|
||||||
|
const chain = chains.find(ch => ch.id === c.chainId)
|
||||||
|
const completedCount = c.completedTasks
|
||||||
|
const nextTask = chain?.tasks[completedCount] || null
|
||||||
|
|
||||||
|
return {
|
||||||
|
chainId: c.chainId,
|
||||||
|
name: c.chainName,
|
||||||
|
progress: c.progress,
|
||||||
|
nextTask,
|
||||||
|
estimatedTimeToComplete: (c.totalTasks - c.completedTasks) * 10 // 10 мин на задание
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
recentAchievements: [], // Требует истории
|
||||||
|
attemptsStats: {
|
||||||
|
totalAttempts: stats.totalSubmissions,
|
||||||
|
successfulAttempts: stats.completedTasks,
|
||||||
|
successRate: (stats.completedTasks / stats.totalSubmissions) * 100
|
||||||
|
},
|
||||||
|
recommendations: generateRecommendations(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRecommendations(stats: UserStats): Array<{type: string, message: string, actionLink: string}> {
|
||||||
|
const recommendations = []
|
||||||
|
|
||||||
|
// Если есть задания требующие доработки
|
||||||
|
if (stats.needsRevisionTasks > 0) {
|
||||||
|
recommendations.push({
|
||||||
|
type: 'retry',
|
||||||
|
message: `У вас ${stats.needsRevisionTasks} заданий требуют доработки`,
|
||||||
|
actionLink: '/tasks?status=needs_revision'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если есть начатые цепочки
|
||||||
|
const inProgressChains = stats.chainStats.filter(c => c.progress > 0 && c.progress < 100)
|
||||||
|
if (inProgressChains.length > 0) {
|
||||||
|
recommendations.push({
|
||||||
|
type: 'continue',
|
||||||
|
message: `Продолжите цепочку "${inProgressChains[0].chainName}"`,
|
||||||
|
actionLink: `/chain/${inProgressChains[0].chainId}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Admin Dashboard (для преподавателя)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AdminDashboard {
|
||||||
|
// Системные метрики
|
||||||
|
system: {
|
||||||
|
totalUsers: number
|
||||||
|
activeUsers24h: number
|
||||||
|
totalTasks: number
|
||||||
|
totalChains: number
|
||||||
|
queueStatus: {
|
||||||
|
length: number
|
||||||
|
processing: number
|
||||||
|
avgWaitTime: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метрики заданий
|
||||||
|
taskMetrics: Array<{
|
||||||
|
taskId: string
|
||||||
|
title: string
|
||||||
|
attemptsCount: number
|
||||||
|
successRate: number
|
||||||
|
avgAttempts: number
|
||||||
|
avgTimeToComplete: number
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard' // на основе метрик
|
||||||
|
}>
|
||||||
|
|
||||||
|
// Активность пользователей
|
||||||
|
userActivity: {
|
||||||
|
registrationsToday: number
|
||||||
|
submissionsToday: number
|
||||||
|
peakHours: Array<{ hour: number, count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проблемные области
|
||||||
|
issues: Array<{
|
||||||
|
type: 'low_success_rate' | 'high_attempts' | 'long_queue'
|
||||||
|
severity: 'low' | 'medium' | 'high'
|
||||||
|
message: string
|
||||||
|
affectedEntity: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анализ сложности задания
|
||||||
|
function analyzeDifficulty(
|
||||||
|
successRate: number,
|
||||||
|
avgAttempts: number
|
||||||
|
): 'easy' | 'medium' | 'hard' {
|
||||||
|
if (successRate > 70 && avgAttempts < 2) return 'easy'
|
||||||
|
if (successRate > 40 && avgAttempts < 3) return 'medium'
|
||||||
|
return 'hard'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определение проблем
|
||||||
|
function detectIssues(stats: SystemStats): Array<any> {
|
||||||
|
const issues = []
|
||||||
|
|
||||||
|
// Длинная очередь
|
||||||
|
if (stats.queue.queueLength > 50) {
|
||||||
|
issues.push({
|
||||||
|
type: 'long_queue',
|
||||||
|
severity: 'high',
|
||||||
|
message: `Очередь содержит ${stats.queue.queueLength} заданий`,
|
||||||
|
affectedEntity: 'system'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Низкий success rate системы
|
||||||
|
const systemSuccessRate = (stats.submissions.accepted / stats.submissions.total) * 100
|
||||||
|
if (systemSuccessRate < 30) {
|
||||||
|
issues.push({
|
||||||
|
type: 'low_success_rate',
|
||||||
|
severity: 'medium',
|
||||||
|
message: `Общий процент принятых заданий всего ${systemSuccessRate.toFixed(1)}%`,
|
||||||
|
affectedEntity: 'system'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Визуализация метрик
|
||||||
|
|
||||||
|
### 1. Progress Chart (круговая диаграмма)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ProgressChartData {
|
||||||
|
completed: number
|
||||||
|
inProgress: number
|
||||||
|
needsRevision: number
|
||||||
|
notStarted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент для отображения (концепт)
|
||||||
|
function ProgressChart({ data }: { data: ProgressChartData }) {
|
||||||
|
const total = Object.values(data).reduce((a, b) => a + b, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="progress-chart">
|
||||||
|
<svg viewBox="0 0 100 100">
|
||||||
|
{/* Реализация круговой диаграммы */}
|
||||||
|
</svg>
|
||||||
|
<div className="legend">
|
||||||
|
<div>✅ Завершено: {data.completed}</div>
|
||||||
|
<div>🔄 В процессе: {data.inProgress}</div>
|
||||||
|
<div>❌ Доработка: {data.needsRevision}</div>
|
||||||
|
<div>⚪ Не начато: {data.notStarted}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Timeline Chart (время проверки)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TimelineData {
|
||||||
|
submissions: Array<{
|
||||||
|
timestamp: string
|
||||||
|
checkTime: number
|
||||||
|
status: 'accepted' | 'needs_revision'
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// График времени проверки по времени суток
|
||||||
|
function TimelineChart({ data }: { data: TimelineData }) {
|
||||||
|
const hourlyData = new Array(24).fill(0).map((_, hour) => {
|
||||||
|
const submissions = data.submissions.filter(s =>
|
||||||
|
new Date(s.timestamp).getHours() === hour
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hour,
|
||||||
|
count: submissions.length,
|
||||||
|
avgCheckTime: submissions.length > 0
|
||||||
|
? submissions.reduce((sum, s) => sum + s.checkTime, 0) / submissions.length
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="timeline-chart">
|
||||||
|
{/* Реализация bar chart */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Heatmap (активность по дням)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface HeatmapData {
|
||||||
|
dates: Array<{
|
||||||
|
date: string // YYYY-MM-DD
|
||||||
|
submissions: number
|
||||||
|
successRate: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Визуализация активности пользователя
|
||||||
|
function ActivityHeatmap({ data }: { data: HeatmapData }) {
|
||||||
|
return (
|
||||||
|
<div className="activity-heatmap">
|
||||||
|
{data.dates.map(day => (
|
||||||
|
<div
|
||||||
|
key={day.date}
|
||||||
|
className="heatmap-cell"
|
||||||
|
style={{
|
||||||
|
opacity: day.submissions / 10, // Интенсивность цвета
|
||||||
|
backgroundColor: day.successRate > 50 ? 'green' : 'red'
|
||||||
|
}}
|
||||||
|
title={`${day.date}: ${day.submissions} попыток, ${day.successRate}% успех`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔔 Real-time уведомления
|
||||||
|
|
||||||
|
### События для отслеживания
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum ChallengeEventType {
|
||||||
|
SUBMISSION_QUEUED = 'submission_queued',
|
||||||
|
SUBMISSION_CHECKING = 'submission_checking',
|
||||||
|
SUBMISSION_COMPLETED = 'submission_completed',
|
||||||
|
TASK_COMPLETED = 'task_completed',
|
||||||
|
CHAIN_COMPLETED = 'chain_completed',
|
||||||
|
ACHIEVEMENT_UNLOCKED = 'achievement_unlocked'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChallengeEvent {
|
||||||
|
type: ChallengeEventType
|
||||||
|
timestamp: string
|
||||||
|
userId: string
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event emitter для уведомлений
|
||||||
|
class ChallengeEventEmitter {
|
||||||
|
private listeners: Map<ChallengeEventType, Array<(event: ChallengeEvent) => void>> = new Map()
|
||||||
|
|
||||||
|
on(type: ChallengeEventType, callback: (event: ChallengeEvent) => void) {
|
||||||
|
if (!this.listeners.has(type)) {
|
||||||
|
this.listeners.set(type, [])
|
||||||
|
}
|
||||||
|
this.listeners.get(type)!.push(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: ChallengeEvent) {
|
||||||
|
const callbacks = this.listeners.get(event.type) || []
|
||||||
|
callbacks.forEach(cb => cb(event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Использование
|
||||||
|
const events = new ChallengeEventEmitter()
|
||||||
|
|
||||||
|
events.on(ChallengeEventType.TASK_COMPLETED, (event) => {
|
||||||
|
// Показать toast уведомление
|
||||||
|
showNotification('✅ Задание выполнено!', 'success')
|
||||||
|
|
||||||
|
// Обновить статистику
|
||||||
|
refreshStats()
|
||||||
|
|
||||||
|
// Отправить аналитику
|
||||||
|
analytics.track('task_completed', event.data)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Адаптивная аналитика
|
||||||
|
|
||||||
|
### Мобильная версия дашборда
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface MobileDashboard {
|
||||||
|
// Упрощенные метрики для мобильных
|
||||||
|
quickStats: {
|
||||||
|
completedToday: number
|
||||||
|
currentStreak: number
|
||||||
|
nextTask: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Минимальные графики
|
||||||
|
weekProgress: number[] // 7 последних дней
|
||||||
|
|
||||||
|
// Быстрые действия
|
||||||
|
quickActions: Array<{
|
||||||
|
label: string
|
||||||
|
action: () => void
|
||||||
|
icon: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 UI Components для метрик
|
||||||
|
|
||||||
|
### Stat Card Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: number | string
|
||||||
|
change?: number // % изменение
|
||||||
|
trend?: 'up' | 'down'
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, change, trend, icon }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-header">
|
||||||
|
<span className="stat-icon">{icon}</span>
|
||||||
|
<span className="stat-title">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">{value}</div>
|
||||||
|
{change && (
|
||||||
|
<div className={`stat-change ${trend}`}>
|
||||||
|
{trend === 'up' ? '↑' : '↓'} {Math.abs(change)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Использование
|
||||||
|
<StatCard
|
||||||
|
title="Задания завершено"
|
||||||
|
value={42}
|
||||||
|
change={15}
|
||||||
|
trend="up"
|
||||||
|
icon="✅"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 A/B Testing
|
||||||
|
|
||||||
|
### Метрики для тестирования
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ABTestMetrics {
|
||||||
|
variant: 'A' | 'B'
|
||||||
|
|
||||||
|
// Конверсионные метрики
|
||||||
|
submissionRate: number // % пользователей, отправивших хотя бы одно задание
|
||||||
|
completionRate: number // % завершенных заданий
|
||||||
|
retryRate: number // % повторных попыток
|
||||||
|
|
||||||
|
// Временные метрики
|
||||||
|
timeToFirstSubmission: number
|
||||||
|
sessionDuration: number
|
||||||
|
|
||||||
|
// Качественные метрики
|
||||||
|
satisfactionScore?: number // если есть опрос
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сравнение вариантов
|
||||||
|
function compareVariants(variantA: ABTestMetrics, variantB: ABTestMetrics) {
|
||||||
|
return {
|
||||||
|
submissionRateDiff: ((variantB.submissionRate - variantA.submissionRate) / variantA.submissionRate) * 100,
|
||||||
|
completionRateDiff: ((variantB.completionRate - variantA.completionRate) / variantA.completionRate) * 100,
|
||||||
|
winner: variantB.completionRate > variantA.completionRate ? 'B' : 'A'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Экспорт данных
|
||||||
|
|
||||||
|
### CSV Export
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function exportUserProgress(userId: string): Promise<string> {
|
||||||
|
const stats = await challengeAPI.getUserStats(userId)
|
||||||
|
const submissions = await challengeAPI.getSubmissions(userId)
|
||||||
|
|
||||||
|
let csv = 'Task,Status,Attempts,Last Attempt,Feedback\n'
|
||||||
|
|
||||||
|
stats.taskStats.forEach(task => {
|
||||||
|
csv += `"${task.taskTitle}","${task.status}",${task.totalAttempts},"${task.lastAttemptAt || 'N/A'}",""\n`
|
||||||
|
})
|
||||||
|
|
||||||
|
return csv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скачивание файла
|
||||||
|
function downloadCSV(csv: string, filename: string) {
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Чек-лист для фронтенд-разработчика
|
||||||
|
|
||||||
|
- [ ] Интегрировать API клиент
|
||||||
|
- [ ] Настроить Context для state management
|
||||||
|
- [ ] Реализовать polling механизм
|
||||||
|
- [ ] Добавить Personal Dashboard
|
||||||
|
- [ ] Создать визуализации прогресса
|
||||||
|
- [ ] Настроить event tracking
|
||||||
|
- [ ] Добавить offline support
|
||||||
|
- [ ] Реализовать экспорт данных
|
||||||
|
- [ ] Добавить A/B тестирование
|
||||||
|
- [ ] Настроить мониторинг ошибок
|
||||||
|
- [ ] Оптимизировать для мобильных
|
||||||
|
- [ ] Добавить accessibility features
|
||||||
|
|
||||||
|
## 📚 Полезные ресурсы
|
||||||
|
|
||||||
|
- **API документация**: `CHALLENGE_API_README.md`
|
||||||
|
- **Архитектура**: `CHALLENGE_ARCHITECTURE.md`
|
||||||
|
- **React пример**: `CHALLENGE_REACT_EXAMPLE.md`
|
||||||
|
- **Быстрый старт**: `CHALLENGE_QUICK_START.md`
|
||||||
|
|
||||||
|
Используйте эти метрики и компоненты для создания информативного и user-friendly интерфейса!
|
||||||
|
|
||||||
1125
docs/CHALLENGE_FRONTEND_GUIDE.md
Normal file
1125
docs/CHALLENGE_FRONTEND_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
1060
docs/CHALLENGE_REACT_EXAMPLE.md
Normal file
1060
docs/CHALLENGE_REACT_EXAMPLE.md
Normal file
File diff suppressed because it is too large
Load Diff
178
docs/README.md
Normal file
178
docs/README.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Challenge Service - Frontend Documentation
|
||||||
|
|
||||||
|
Документация для фронтенд-разработчиков и преподавателей.
|
||||||
|
|
||||||
|
## 📚 Документы
|
||||||
|
|
||||||
|
### Для всех фронтенд-разработчиков
|
||||||
|
|
||||||
|
1. **[CHALLENGE_FRONTEND_GUIDE.md](./CHALLENGE_FRONTEND_GUIDE.md)**
|
||||||
|
- Основное руководство по интеграции
|
||||||
|
- Сценарии использования
|
||||||
|
- Структуры данных (TypeScript)
|
||||||
|
- API взаимодействие
|
||||||
|
- Готовые компоненты React
|
||||||
|
- Best practices
|
||||||
|
|
||||||
|
2. **[CHALLENGE_REACT_EXAMPLE.md](./CHALLENGE_REACT_EXAMPLE.md)**
|
||||||
|
- Полный пример React + TypeScript приложения
|
||||||
|
- Готовый код компонентов
|
||||||
|
- Custom hooks
|
||||||
|
- State management
|
||||||
|
- Стили
|
||||||
|
|
||||||
|
3. **[CHALLENGE_ANALYTICS_SUMMARY.md](./CHALLENGE_ANALYTICS_SUMMARY.md)**
|
||||||
|
- Метрики для отслеживания
|
||||||
|
- Дашборды (Personal, Admin)
|
||||||
|
- Визуализация данных
|
||||||
|
- Real-time уведомления
|
||||||
|
- A/B тестирование
|
||||||
|
|
||||||
|
### Для преподавателей 🔒
|
||||||
|
|
||||||
|
4. **[TEACHER_GUIDE.md](./TEACHER_GUIDE.md)**
|
||||||
|
- Работа со скрытыми инструкциями для LLM
|
||||||
|
- Настройка Keycloak
|
||||||
|
- Создание и редактирование заданий
|
||||||
|
- UI компоненты для преподавателей
|
||||||
|
- Best practices
|
||||||
|
- Примеры реальных сценариев
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Для студентов (обычные пользователи)
|
||||||
|
|
||||||
|
1. Прочитайте [CHALLENGE_FRONTEND_GUIDE.md](./CHALLENGE_FRONTEND_GUIDE.md)
|
||||||
|
2. Посмотрите примеры в [CHALLENGE_REACT_EXAMPLE.md](./CHALLENGE_REACT_EXAMPLE.md)
|
||||||
|
3. Используйте готовые компоненты как основу
|
||||||
|
|
||||||
|
### Для преподавателей
|
||||||
|
|
||||||
|
1. Прочитайте [TEACHER_GUIDE.md](./TEACHER_GUIDE.md) 🔒
|
||||||
|
2. Настройте Keycloak (роль `teacher`)
|
||||||
|
3. Используйте скрытые инструкции для улучшения проверки
|
||||||
|
|
||||||
|
## 🎯 Ключевые особенности
|
||||||
|
|
||||||
|
### Для студентов
|
||||||
|
|
||||||
|
- ✅ Простая аутентификация (nickname)
|
||||||
|
- ✅ Просмотр цепочек с прогрессом
|
||||||
|
- ✅ Отправка решений
|
||||||
|
- ✅ Real-time отслеживание проверки
|
||||||
|
- ✅ Персональная статистика
|
||||||
|
- ✅ Feedback от AI
|
||||||
|
|
||||||
|
### Для преподавателей 🔒
|
||||||
|
|
||||||
|
- ✅ Создание заданий через Keycloak
|
||||||
|
- ✅ Скрытые инструкции для LLM
|
||||||
|
- ✅ Управление цепочками
|
||||||
|
- ✅ Просмотр статистики
|
||||||
|
- ✅ Контроль качества проверок
|
||||||
|
|
||||||
|
## 📊 Структура данных
|
||||||
|
|
||||||
|
### Task (с учетом ролей)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Для студента
|
||||||
|
interface ChallengeTask {
|
||||||
|
_id: string
|
||||||
|
title: string
|
||||||
|
description: string // Markdown
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для преподавателя (teacher)
|
||||||
|
interface ChallengeTask {
|
||||||
|
_id: string
|
||||||
|
title: string
|
||||||
|
description: string // Markdown
|
||||||
|
hiddenInstructions: string // 🔒 Только для преподавателей
|
||||||
|
creator: object // 🔒 Только для преподавателей
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Авторизация
|
||||||
|
|
||||||
|
### Студенты
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Простая регистрация по nickname
|
||||||
|
POST /api/challenge/auth
|
||||||
|
{ "nickname": "student123" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Преподаватели
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Запросы с токеном Keycloak
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer <keycloak_token>'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Требуется роль 'teacher' в клиенте 'journal'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 UI Components
|
||||||
|
|
||||||
|
### Для студентов
|
||||||
|
|
||||||
|
- `AuthForm` - вход по nickname
|
||||||
|
- `ChainList` - список цепочек
|
||||||
|
- `TaskView` - просмотр и решение
|
||||||
|
- `CheckStatus` - отслеживание проверки
|
||||||
|
- `ResultView` - результат
|
||||||
|
- `UserStats` - статистика
|
||||||
|
|
||||||
|
### Для преподавателей 🔒
|
||||||
|
|
||||||
|
- `TeacherTaskForm` - создание с hiddenInstructions
|
||||||
|
- `TaskCard` - с индикацией скрытых инструкций
|
||||||
|
- `AdminDashboard` - полная статистика
|
||||||
|
|
||||||
|
## 📖 Примеры использования
|
||||||
|
|
||||||
|
### Создание задания (преподаватель)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const task = await createTask({
|
||||||
|
title: "Реализовать сортировку",
|
||||||
|
description: "# Задание\n\nНапишите функцию...",
|
||||||
|
hiddenInstructions: "Проверь сложность алгоритма O(n log n)" // 🔒
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отправка решения (студент)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { queueId } = await submitSolution(userId, taskId, result)
|
||||||
|
|
||||||
|
// Polling
|
||||||
|
const submission = await pollCheckStatus(queueId, (status) => {
|
||||||
|
console.log('Status:', status.status)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Технологии
|
||||||
|
|
||||||
|
- React + TypeScript
|
||||||
|
- Keycloak для авторизации преподавателей
|
||||||
|
- Context API / Redux для state
|
||||||
|
- React Markdown
|
||||||
|
- Fetch API
|
||||||
|
|
||||||
|
## 📚 Дополнительные ресурсы
|
||||||
|
|
||||||
|
- [API документация](../CHALLENGE_API_README.md)
|
||||||
|
- [Архитектура системы](../CHALLENGE_ARCHITECTURE.md)
|
||||||
|
- [Быстрый старт](../CHALLENGE_QUICK_START.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Версия:** 1.0.0
|
||||||
|
**Дата:** 29 октября 2025
|
||||||
|
**Статус:** ✅ Production Ready
|
||||||
|
|
||||||
439
docs/TEACHER_GUIDE.md
Normal file
439
docs/TEACHER_GUIDE.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# Challenge Service - Руководство для преподавателей
|
||||||
|
|
||||||
|
Специальное руководство для пользователей с ролью `teacher` в Keycloak.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
Для создания и редактирования заданий и цепочек необходимо:
|
||||||
|
|
||||||
|
1. Быть авторизованным через Keycloak
|
||||||
|
2. Иметь роль `teacher` в клиенте `journal`
|
||||||
|
|
||||||
|
## Особенности для преподавателей
|
||||||
|
|
||||||
|
### 1. Скрытые инструкции для LLM
|
||||||
|
|
||||||
|
При создании заданий вы можете добавить **скрытые инструкции** (`hiddenInstructions`), которые:
|
||||||
|
|
||||||
|
- ✅ Видны только преподавателям
|
||||||
|
- ✅ Передаются в LLM при проверке
|
||||||
|
- ❌ Не видны студентам
|
||||||
|
- ❌ Не отображаются в интерфейсе студента
|
||||||
|
|
||||||
|
#### Примеры использования
|
||||||
|
|
||||||
|
**Пример 1: Контроль сложности**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Реализовать сортировку",
|
||||||
|
"description": "Напишите функцию для сортировки массива чисел",
|
||||||
|
"hiddenInstructions": "Проверь, чтобы сложность алгоритма была не хуже O(n log n). Не принимай bubble sort или простые O(n²) решения."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример 2: Специфичные требования**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "REST API endpoint",
|
||||||
|
"description": "Создайте endpoint для получения списка пользователей",
|
||||||
|
"hiddenInstructions": "Обязательно должна быть пагинация, обработка ошибок и валидация параметров. Если чего-то не хватает - укажи в feedback."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример 3: Стиль кода**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Компонент React",
|
||||||
|
"description": "Создайте компонент для отображения карточки товара",
|
||||||
|
"hiddenInstructions": "Проверь использование TypeScript, правильное применение хуков, и соблюдение best practices React. Код должен быть чистым и читаемым."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример 4: Тонкая настройка проверки**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "SQL запрос",
|
||||||
|
"description": "Напишите запрос для выборки активных пользователей",
|
||||||
|
"hiddenInstructions": "Даже если запрос работает, но неоптимален (например, использует SELECT *), укажи на это в feedback и попроси оптимизировать."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Создание задания через API
|
||||||
|
|
||||||
|
#### С помощью Keycloak токена
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Получение токена (пример для frontend)
|
||||||
|
const keycloakToken = keycloak.token // из keycloak-js
|
||||||
|
|
||||||
|
// Создание задания
|
||||||
|
async function createTask(title: string, description: string, hiddenInstructions: string) {
|
||||||
|
const response = await fetch('http://localhost:8082/api/challenge/task', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${keycloakToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
hiddenInstructions
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### С помощью curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Получить токен от Keycloak
|
||||||
|
TOKEN="your_keycloak_token"
|
||||||
|
|
||||||
|
# Создать задание
|
||||||
|
curl -X POST http://localhost:8082/api/challenge/task \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"title": "Написать функцию",
|
||||||
|
"description": "# Задание\n\nНапишите функцию для...",
|
||||||
|
"hiddenInstructions": "Проверь производительность и обработку ошибок"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. UI компоненты для преподавателей
|
||||||
|
|
||||||
|
#### TaskForm с скрытыми инструкциями
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useState } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
|
||||||
|
interface TaskFormProps {
|
||||||
|
onSubmit: (task: { title: string; description: string; hiddenInstructions: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeacherTaskForm({ onSubmit }: TaskFormProps) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [hiddenInstructions, setHiddenInstructions] = useState('')
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit({ title, description, hiddenInstructions })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Заголовок задания</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Описание (Markdown)</label>
|
||||||
|
<div className="tabs">
|
||||||
|
<button type="button" onClick={() => setShowPreview(false)}>Редактор</button>
|
||||||
|
<button type="button" onClick={() => setShowPreview(true)}>Превью</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview ? (
|
||||||
|
<div className="markdown-preview">
|
||||||
|
<ReactMarkdown>{description}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
required
|
||||||
|
rows={15}
|
||||||
|
placeholder="# Заголовок\n\nОписание задания..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group highlight">
|
||||||
|
<label>
|
||||||
|
🔒 Скрытые инструкции для LLM
|
||||||
|
<span className="info-tooltip">
|
||||||
|
Эти инструкции увидит только LLM при проверке.
|
||||||
|
Студенты их не увидят.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={hiddenInstructions}
|
||||||
|
onChange={(e) => setHiddenInstructions(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder="Дополнительные требования к проверке..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Создать задание</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TaskCard с индикацией скрытых инструкций
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: ChallengeTask
|
||||||
|
isTeacher: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskCard({ task, isTeacher }: TaskCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="task-card">
|
||||||
|
<h3>{task.title}</h3>
|
||||||
|
|
||||||
|
<div className="task-description">
|
||||||
|
<ReactMarkdown>{task.description}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isTeacher && task.hiddenInstructions && (
|
||||||
|
<div className="hidden-instructions-indicator">
|
||||||
|
<span className="lock-icon">🔒</span>
|
||||||
|
<span>Содержит скрытые инструкции для LLM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isTeacher && task.creator && (
|
||||||
|
<div className="task-meta">
|
||||||
|
<span>Создал: {task.creator.preferred_username}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Настройка Keycloak
|
||||||
|
|
||||||
|
#### Добавление роли teacher
|
||||||
|
|
||||||
|
1. Войдите в админ панель Keycloak
|
||||||
|
2. Выберите realm (например, `bro-js` или `itpark`)
|
||||||
|
3. Перейдите в **Clients** → `journal`
|
||||||
|
4. Перейдите на вкладку **Roles**
|
||||||
|
5. Добавьте роль `teacher`, если её нет
|
||||||
|
6. Назначьте роль нужным пользователям через **Users** → [пользователь] → **Role Mappings**
|
||||||
|
|
||||||
|
### 5. Best Practices
|
||||||
|
|
||||||
|
#### ✅ Хорошие скрытые инструкции
|
||||||
|
|
||||||
|
```
|
||||||
|
"Проверь, что функция обрабатывает edge cases: пустой массив,
|
||||||
|
один элемент, отрицательные числа. Если что-то упущено - укажи."
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Код должен следовать принципу DRY. Если есть дублирование -
|
||||||
|
отправь на доработку с рекомендацией."
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Обязательна обработка ошибок. Если try-catch отсутствует или
|
||||||
|
неполный - укажи в feedback."
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ❌ Плохие скрытые инструкции
|
||||||
|
|
||||||
|
```
|
||||||
|
"Проверь" // Слишком общее
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Это задание должно быть правильным" // Бессмысленное
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Не принимай, если не идеально" // Слишком строгое, непонятное
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Просмотр скрытых инструкций
|
||||||
|
|
||||||
|
Скрытые инструкции доступны только при запросе с токеном `teacher`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Получить задание (с токеном teacher)
|
||||||
|
async function getTaskAsTeacher(taskId: string) {
|
||||||
|
const response = await fetch(`http://localhost:8082/api/challenge/task/${taskId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${keycloakToken}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data } = await response.json()
|
||||||
|
|
||||||
|
// data.hiddenInstructions будет доступно
|
||||||
|
console.log('Hidden instructions:', data.hiddenInstructions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить задание (без токена или с обычным пользователем)
|
||||||
|
async function getTaskAsStudent(taskId: string) {
|
||||||
|
const response = await fetch(`http://localhost:8082/api/challenge/task/${taskId}`)
|
||||||
|
|
||||||
|
const { data } = await response.json()
|
||||||
|
|
||||||
|
// data.hiddenInstructions будет undefined
|
||||||
|
console.log('Hidden instructions:', data.hiddenInstructions) // undefined
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Редактирование существующих заданий
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function updateTask(
|
||||||
|
taskId: string,
|
||||||
|
updates: {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
hiddenInstructions?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const response = await fetch(`http://localhost:8082/api/challenge/task/${taskId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${keycloakToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пример использования
|
||||||
|
updateTask('507f1f77bcf86cd799439011', {
|
||||||
|
hiddenInstructions: 'Обновленные требования к проверке'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Мониторинг эффективности инструкций
|
||||||
|
|
||||||
|
Отслеживайте, как скрытые инструкции влияют на результаты:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InstructionEffectiveness {
|
||||||
|
taskId: string
|
||||||
|
taskTitle: string
|
||||||
|
hasHiddenInstructions: boolean
|
||||||
|
acceptanceRate: number // % принятых с первой попытки
|
||||||
|
averageFeedbackQuality: number // оценка качества feedback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анализ эффективности
|
||||||
|
async function analyzeInstructionsEffectiveness() {
|
||||||
|
const tasks = await fetchAllTasks()
|
||||||
|
const stats = await fetchSystemStats()
|
||||||
|
|
||||||
|
return tasks.map(task => ({
|
||||||
|
taskId: task.id,
|
||||||
|
taskTitle: task.title,
|
||||||
|
hasHiddenInstructions: !!task.hiddenInstructions,
|
||||||
|
acceptanceRate: calculateAcceptanceRate(task.id, stats),
|
||||||
|
averageFeedbackQuality: calculateFeedbackQuality(task.id, stats)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Шаблоны скрытых инструкций
|
||||||
|
|
||||||
|
#### Для программирования
|
||||||
|
|
||||||
|
```
|
||||||
|
Проверь:
|
||||||
|
1. Корректность алгоритма
|
||||||
|
2. Обработку edge cases
|
||||||
|
3. Сложность алгоритма (должна быть оптимальной)
|
||||||
|
4. Читаемость кода
|
||||||
|
5. Наличие комментариев в сложных местах
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Для веб-разработки
|
||||||
|
|
||||||
|
```
|
||||||
|
Проверь:
|
||||||
|
1. Соответствие HTML семантике
|
||||||
|
2. Доступность (accessibility)
|
||||||
|
3. Responsive design
|
||||||
|
4. Производительность
|
||||||
|
5. Best practices для используемого фреймворка
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Для баз данных
|
||||||
|
|
||||||
|
```
|
||||||
|
Проверь:
|
||||||
|
1. Правильность SQL синтаксиса
|
||||||
|
2. Оптимальность запроса
|
||||||
|
3. Использование индексов
|
||||||
|
4. Защиту от SQL injection
|
||||||
|
5. Читаемость запроса
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. FAQ
|
||||||
|
|
||||||
|
**Q: Что если я не добавлю скрытые инструкции?**
|
||||||
|
A: Задание будет работать нормально. LLM проверит решение на основе только видимого описания.
|
||||||
|
|
||||||
|
**Q: Могут ли студенты как-то увидеть скрытые инструкции?**
|
||||||
|
A: Нет, сервер автоматически фильтрует это поле при запросах без роли teacher.
|
||||||
|
|
||||||
|
**Q: Можно ли изменить скрытые инструкции после создания?**
|
||||||
|
A: Да, используйте PUT /api/challenge/task/:taskId с новым значением hiddenInstructions.
|
||||||
|
|
||||||
|
**Q: Влияют ли скрытые инструкции на все проверки?**
|
||||||
|
A: Да, каждая проверка использует актуальные hiddenInstructions из задания.
|
||||||
|
|
||||||
|
**Q: Можно ли использовать Markdown в скрытых инструкциях?**
|
||||||
|
A: Можно, но это обычный текст. Markdown не рендерится, так как инструкции идут прямо в LLM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примеры реальных сценариев
|
||||||
|
|
||||||
|
### Сценарий 1: Курс по алгоритмам
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Реализовать бинарный поиск",
|
||||||
|
"description": "Напишите функцию binarySearch(arr, target), которая ищет элемент в отсортированном массиве",
|
||||||
|
"hiddenInstructions": "Проверь сложность - должна быть O(log n). Если используется линейный поиск или неоптимальный алгоритм - отклони с объяснением. Также проверь обработку случаев, когда элемент не найден."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сценарий 2: Курс по React
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Форма регистрации",
|
||||||
|
"description": "Создайте компонент формы регистрации с полями email и пароль",
|
||||||
|
"hiddenInstructions": "Обязательна валидация на стороне клиента, использование controlled components, и правильное управление state. Если используются uncontrolled components или нет валидации - отправь на доработку."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сценарий 3: Курс по безопасности
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Безопасный API endpoint",
|
||||||
|
"description": "Создайте endpoint для аутентификации пользователя",
|
||||||
|
"hiddenInstructions": "Критически важно: пароли должны хешироваться, должна быть защита от SQL injection, rate limiting. Если что-то из этого отсутствует - обязательно отклони и подробно объясни риски безопасности."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Используйте скрытые инструкции разумно для повышения качества автоматической проверки! 🎓
|
||||||
|
|
||||||
58
eslint.config.mjs
Normal file
58
eslint.config.mjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import globals from 'globals'
|
||||||
|
import pluginJs from '@eslint/js'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import pluginReact from 'eslint-plugin-react'
|
||||||
|
import stylistic from '@stylistic/eslint-plugin'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
'import/resolver': {
|
||||||
|
node: {
|
||||||
|
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
|
||||||
|
{ languageOptions: { globals: globals.browser } },
|
||||||
|
{
|
||||||
|
ignores: ['stubs/', 'bro.config.js'],
|
||||||
|
},
|
||||||
|
pluginJs.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
pluginReact.configs.flat.recommended,
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
'@stylistic': stylistic,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'sort-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
ignoreCase: false,
|
||||||
|
ignoreDeclarationSort: true,
|
||||||
|
ignoreMemberSort: true,
|
||||||
|
memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
|
||||||
|
allowSeparatedGroups: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
semi: ['error', 'never'],
|
||||||
|
'@stylistic/indent': ['error', 2],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
3
locales/en.json
Normal file
3
locales/en.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"challenge.title": "Challenge"
|
||||||
|
}
|
||||||
3
locales/ru.json
Normal file
3
locales/ru.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"challenge.title": "Challenge"
|
||||||
|
}
|
||||||
12927
package-lock.json
generated
Normal file
12927
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "challenge-admin",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "./src/index.tsx",
|
||||||
|
"scripts": {
|
||||||
|
"test": "exit 0",
|
||||||
|
"start": "brojs server --port=8099 --with-open-browser",
|
||||||
|
"build": "npm run clean && brojs build --dev",
|
||||||
|
"build:prod": "npm run clean && brojs build",
|
||||||
|
"clean": "rimraf dist",
|
||||||
|
"eslint": "npx eslint ./src/**/*",
|
||||||
|
"eslint:fix": "npx eslint ./src/**/* --fix"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@85.143.175.152:222/bro-js/challenge-admin-pl.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@brojs/cli": "^1.9.4",
|
||||||
|
"@chakra-ui/react": "^3.2.0",
|
||||||
|
"@emotion/react": "^11.13.5",
|
||||||
|
"@eslint/js": "^9.11.0",
|
||||||
|
"@reduxjs/toolkit": "^2.9.2",
|
||||||
|
"@stylistic/eslint-plugin": "^2.8.0",
|
||||||
|
"@types/node": "^22.18.13",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"eslint": "^9.11.0",
|
||||||
|
"eslint-plugin-react": "^7.36.1",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"keycloak-js": "^26.2.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"typescript-eslint": "^8.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/__data__/api/api.ts
Normal file
168
src/__data__/api/api.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||||
|
import { getConfigValue } from '@brojs/cli'
|
||||||
|
|
||||||
|
import { keycloak } from '../kc'
|
||||||
|
import type {
|
||||||
|
ChallengeTask,
|
||||||
|
ChallengeChain,
|
||||||
|
ChallengeUser,
|
||||||
|
ChallengeSubmission,
|
||||||
|
SystemStats,
|
||||||
|
UserStats,
|
||||||
|
CreateTaskRequest,
|
||||||
|
UpdateTaskRequest,
|
||||||
|
CreateChainRequest,
|
||||||
|
UpdateChainRequest,
|
||||||
|
} from '../../types/challenge'
|
||||||
|
|
||||||
|
export const api = createApi({
|
||||||
|
reducerPath: 'api',
|
||||||
|
baseQuery: fetchBaseQuery({
|
||||||
|
baseUrl: getConfigValue('challenge-admin.api'),
|
||||||
|
fetchFn: async (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit | undefined,
|
||||||
|
) => {
|
||||||
|
const response = await fetch(input, init)
|
||||||
|
|
||||||
|
if (response.status === 403) keycloak.login()
|
||||||
|
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json;charset=utf-8',
|
||||||
|
},
|
||||||
|
prepareHeaders: (headers) => {
|
||||||
|
headers.set('Authorization', `Bearer ${keycloak.token}`)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tagTypes: ['Task', 'Chain', 'User', 'Submission', 'Stats'],
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
// Tasks
|
||||||
|
getTasks: builder.query<ChallengeTask[], void>({
|
||||||
|
query: () => '/challenge/tasks',
|
||||||
|
transformResponse: (response: { data: ChallengeTask[] }) => response.data,
|
||||||
|
providesTags: ['Task'],
|
||||||
|
}),
|
||||||
|
getTask: builder.query<ChallengeTask, string>({
|
||||||
|
query: (id) => `/challenge/task/${id}`,
|
||||||
|
transformResponse: (response: { data: ChallengeTask }) => response.data,
|
||||||
|
providesTags: (_result, _error, id) => [{ type: 'Task', id }],
|
||||||
|
}),
|
||||||
|
createTask: builder.mutation<ChallengeTask, CreateTaskRequest>({
|
||||||
|
query: (body) => ({
|
||||||
|
url: '/challenge/task',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
transformResponse: (response: { data: ChallengeTask }) => response.data,
|
||||||
|
invalidatesTags: ['Task'],
|
||||||
|
}),
|
||||||
|
updateTask: builder.mutation<ChallengeTask, { id: string; data: UpdateTaskRequest }>({
|
||||||
|
query: ({ id, data }) => ({
|
||||||
|
url: `/challenge/task/${id}`,
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
transformResponse: (response: { data: ChallengeTask }) => response.data,
|
||||||
|
invalidatesTags: (_result, _error, { id }) => [{ type: 'Task', id }, 'Task'],
|
||||||
|
}),
|
||||||
|
deleteTask: builder.mutation<void, string>({
|
||||||
|
query: (id) => ({
|
||||||
|
url: `/challenge/task/${id}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
invalidatesTags: ['Task', 'Chain'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Chains
|
||||||
|
getChains: builder.query<ChallengeChain[], void>({
|
||||||
|
query: () => '/challenge/chains',
|
||||||
|
transformResponse: (response: { data: ChallengeChain[] }) => response.data,
|
||||||
|
providesTags: ['Chain'],
|
||||||
|
}),
|
||||||
|
getChain: builder.query<ChallengeChain, string>({
|
||||||
|
query: (id) => `/challenge/chain/${id}`,
|
||||||
|
transformResponse: (response: { data: ChallengeChain }) => response.data,
|
||||||
|
providesTags: (_result, _error, id) => [{ type: 'Chain', id }],
|
||||||
|
}),
|
||||||
|
createChain: builder.mutation<ChallengeChain, CreateChainRequest>({
|
||||||
|
query: (body) => ({
|
||||||
|
url: '/challenge/chain',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
transformResponse: (response: { data: ChallengeChain }) => response.data,
|
||||||
|
invalidatesTags: ['Chain'],
|
||||||
|
}),
|
||||||
|
updateChain: builder.mutation<ChallengeChain, { id: string; data: UpdateChainRequest }>({
|
||||||
|
query: ({ id, data }) => ({
|
||||||
|
url: `/challenge/chain/${id}`,
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
transformResponse: (response: { data: ChallengeChain }) => response.data,
|
||||||
|
invalidatesTags: (_result, _error, { id }) => [{ type: 'Chain', id }, 'Chain'],
|
||||||
|
}),
|
||||||
|
deleteChain: builder.mutation<void, string>({
|
||||||
|
query: (id) => ({
|
||||||
|
url: `/challenge/chain/${id}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
invalidatesTags: ['Chain'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Users
|
||||||
|
getUsers: builder.query<ChallengeUser[], void>({
|
||||||
|
query: () => '/challenge/users',
|
||||||
|
transformResponse: (response: { data: ChallengeUser[] }) => response.data,
|
||||||
|
providesTags: ['User'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
getSystemStats: builder.query<SystemStats, void>({
|
||||||
|
query: () => '/challenge/stats',
|
||||||
|
transformResponse: (response: { data: SystemStats }) => response.data,
|
||||||
|
providesTags: ['Stats'],
|
||||||
|
}),
|
||||||
|
getUserStats: builder.query<UserStats, string>({
|
||||||
|
query: (userId) => `/challenge/user/${userId}/stats`,
|
||||||
|
transformResponse: (response: { data: UserStats }) => response.data,
|
||||||
|
providesTags: (_result, _error, userId) => [{ type: 'User', id: userId }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Submissions
|
||||||
|
getUserSubmissions: builder.query<ChallengeSubmission[], { userId: string; taskId?: string }>({
|
||||||
|
query: ({ userId, taskId }) => {
|
||||||
|
const params = taskId ? `?taskId=${taskId}` : ''
|
||||||
|
return `/challenge/user/${userId}/submissions${params}`
|
||||||
|
},
|
||||||
|
transformResponse: (response: { data: ChallengeSubmission[] }) => response.data,
|
||||||
|
providesTags: ['Submission'],
|
||||||
|
}),
|
||||||
|
getAllSubmissions: builder.query<ChallengeSubmission[], void>({
|
||||||
|
query: () => '/challenge/submissions',
|
||||||
|
transformResponse: (response: { data: ChallengeSubmission[] }) => response.data,
|
||||||
|
providesTags: ['Submission'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetTasksQuery,
|
||||||
|
useGetTaskQuery,
|
||||||
|
useCreateTaskMutation,
|
||||||
|
useUpdateTaskMutation,
|
||||||
|
useDeleteTaskMutation,
|
||||||
|
useGetChainsQuery,
|
||||||
|
useGetChainQuery,
|
||||||
|
useCreateChainMutation,
|
||||||
|
useUpdateChainMutation,
|
||||||
|
useDeleteChainMutation,
|
||||||
|
useGetUsersQuery,
|
||||||
|
useGetSystemStatsQuery,
|
||||||
|
useGetUserStatsQuery,
|
||||||
|
useGetUserSubmissionsQuery,
|
||||||
|
useGetAllSubmissionsQuery,
|
||||||
|
} = api
|
||||||
|
|
||||||
8
src/__data__/kc.ts
Normal file
8
src/__data__/kc.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import Keycloak from 'keycloak-js'
|
||||||
|
|
||||||
|
export const keycloak = new Keycloak({
|
||||||
|
url: KC_URL,
|
||||||
|
realm: KC_REALM,
|
||||||
|
clientId: KC_CLIENT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
10
src/__data__/slices/user.ts
Normal file
10
src/__data__/slices/user.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
import { UserData } from '../types'
|
||||||
|
|
||||||
|
export const userSlice = createSlice({
|
||||||
|
name: 'user',
|
||||||
|
initialState: null as UserData,
|
||||||
|
reducers: {
|
||||||
|
}
|
||||||
|
})
|
||||||
24
src/__data__/store.ts
Normal file
24
src/__data__/store.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit'
|
||||||
|
import { TypedUseSelectorHook, useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
import { api } from './api/api'
|
||||||
|
import { userSlice } from './slices/user'
|
||||||
|
|
||||||
|
export const createStore = (preloadedState = {}) =>
|
||||||
|
configureStore({
|
||||||
|
preloadedState,
|
||||||
|
reducer: {
|
||||||
|
[api.reducerPath]: api.reducer,
|
||||||
|
user: userSlice.reducer,
|
||||||
|
},
|
||||||
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
getDefaultMiddleware({
|
||||||
|
immutableCheck: false,
|
||||||
|
serializableCheck: false,
|
||||||
|
}).concat(api.middleware),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Store = ReturnType<ReturnType<typeof createStore>['getState']>
|
||||||
|
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<Store> = useSelector
|
||||||
|
|
||||||
36
src/__data__/urls.ts
Normal file
36
src/__data__/urls.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { getNavigation, getNavigationValue } from '@brojs/cli'
|
||||||
|
|
||||||
|
import pkg from '../../package.json'
|
||||||
|
|
||||||
|
const baseUrl = getNavigationValue(`${pkg.name}.main`)
|
||||||
|
const navs = getNavigation()
|
||||||
|
const makeUrl = (url: string) => baseUrl + url
|
||||||
|
|
||||||
|
export const URLs = {
|
||||||
|
baseUrl,
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
dashboard: makeUrl(''),
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
tasks: makeUrl('/tasks'),
|
||||||
|
taskNew: makeUrl('/tasks/new'),
|
||||||
|
taskEdit: (id: string) => makeUrl(`/tasks/${id}`),
|
||||||
|
taskEditPath: makeUrl('/tasks/:id'),
|
||||||
|
|
||||||
|
// Chains
|
||||||
|
chains: makeUrl('/chains'),
|
||||||
|
chainNew: makeUrl('/chains/new'),
|
||||||
|
chainEdit: (id: string) => makeUrl(`/chains/${id}`),
|
||||||
|
chainEditPath: makeUrl('/chains/:id'),
|
||||||
|
|
||||||
|
// Users
|
||||||
|
users: makeUrl('/users'),
|
||||||
|
|
||||||
|
// Submissions
|
||||||
|
submissions: makeUrl('/submissions'),
|
||||||
|
|
||||||
|
// External links
|
||||||
|
challengePlayer: navs['link.challenge'] || '/challenge',
|
||||||
|
}
|
||||||
|
|
||||||
26
src/app.tsx
Normal file
26
src/app.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { Dashboard } from './dashboard'
|
||||||
|
import { Provider } from './theme'
|
||||||
|
import { Provider as ReduxProvider } from 'react-redux'
|
||||||
|
import { Toaster } from './components/ui/toaster'
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
|
const App = ({ store }: PropsWithChildren<{ store?: any }>) => {
|
||||||
|
if (!store) {
|
||||||
|
return <div>Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReduxProvider store={store}>
|
||||||
|
<Provider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Dashboard />
|
||||||
|
</BrowserRouter>
|
||||||
|
<Toaster />
|
||||||
|
</Provider>
|
||||||
|
</ReduxProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
61
src/components/ConfirmDialog.tsx
Normal file
61
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
DialogRoot,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogActionTrigger,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { Button } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Подтвердить',
|
||||||
|
cancelLabel = 'Отмена',
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
{message}
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActionTrigger asChild>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogActionTrigger>
|
||||||
|
<Button
|
||||||
|
colorPalette="red"
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
46
src/components/EmptyState.tsx
Normal file
46
src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Text, VStack, Button } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actionLabel?: string
|
||||||
|
onAction?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
borderRadius="lg"
|
||||||
|
borderWidth="2px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
borderStyle="dashed"
|
||||||
|
p={12}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Text fontSize="4xl">📭</Text>
|
||||||
|
<Text fontSize="lg" fontWeight="semibold" color="gray.700">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{description && (
|
||||||
|
<Text color="gray.600" fontSize="sm">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<Button colorPalette="teal" onClick={onAction} mt={2}>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
33
src/components/ErrorAlert.tsx
Normal file
33
src/components/ErrorAlert.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Text, Button } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
interface ErrorAlertProps {
|
||||||
|
message?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorAlert: React.FC<ErrorAlertProps> = ({
|
||||||
|
message = 'Произошла ошибка при загрузке данных',
|
||||||
|
onRetry,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="red.50"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="red.200"
|
||||||
|
borderRadius="lg"
|
||||||
|
p={6}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Text color="red.700" fontWeight="medium" mb={4}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
{onRetry && (
|
||||||
|
<Button colorPalette="red" size="sm" onClick={onRetry}>
|
||||||
|
Попробовать снова
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
103
src/components/Layout.tsx
Normal file
103
src/components/Layout.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { Box, Container, Flex, HStack, VStack, Button, Text } from '@chakra-ui/react'
|
||||||
|
import { useAppSelector } from '../__data__/store'
|
||||||
|
import { URLs } from '../__data__/urls'
|
||||||
|
import { keycloak } from '../__data__/kc'
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const user = useAppSelector((state) => state.user)
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
keycloak.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNavigateToPlayer = () => {
|
||||||
|
navigate(URLs.challengePlayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
return location.pathname === path
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Dashboard', path: URLs.dashboard },
|
||||||
|
{ label: 'Задания', path: URLs.tasks },
|
||||||
|
{ label: 'Цепочки', path: URLs.chains },
|
||||||
|
{ label: 'Пользователи', path: URLs.users },
|
||||||
|
{ label: 'Попытки', path: URLs.submissions },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box minH="100vh" bg="gray.50">
|
||||||
|
{/* Header */}
|
||||||
|
<Box bg="white" borderBottom="1px" borderColor="gray.200" position="sticky" top={0} zIndex={10}>
|
||||||
|
<Container maxW="container.xl">
|
||||||
|
<Flex h="16" alignItems="center" justifyContent="space-between">
|
||||||
|
<Text fontSize="xl" fontWeight="bold" color="teal.600">
|
||||||
|
Challenge Admin
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<HStack gap={4}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleNavigateToPlayer}
|
||||||
|
>
|
||||||
|
Открыть проигрыватель
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{user.preferred_username || user.email}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
colorPalette="red"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<Box bg="white" borderBottom="1px" borderColor="gray.200">
|
||||||
|
<Container maxW="container.xl">
|
||||||
|
<HStack gap={1} py={2}>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.path}
|
||||||
|
as={Link}
|
||||||
|
to={item.path}
|
||||||
|
size="sm"
|
||||||
|
variant={isActive(item.path) ? 'solid' : 'ghost'}
|
||||||
|
colorPalette={isActive(item.path) ? 'teal' : 'gray'}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Container maxW="container.xl" py={8}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
18
src/components/LoadingSpinner.tsx
Normal file
18
src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Flex, Spinner, Text, VStack } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ message = 'Загрузка...' }) => {
|
||||||
|
return (
|
||||||
|
<Flex justify="center" align="center" minH="400px">
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Spinner size="xl" color="teal.500" />
|
||||||
|
<Text color="gray.600">{message}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
37
src/components/StatCard.tsx
Normal file
37
src/components/StatCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Text, Flex, Icon } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
icon?: React.ReactElement
|
||||||
|
colorScheme?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatCard: React.FC<StatCardProps> = ({ label, value, icon, colorScheme = 'teal' }) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="white"
|
||||||
|
p={6}
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="sm"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
>
|
||||||
|
<Flex align="center" justify="space-between" mb={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600" fontWeight="medium">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{icon && (
|
||||||
|
<Box color={`${colorScheme}.500`}>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
<Text fontSize="3xl" fontWeight="bold" color={`${colorScheme}.600`}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
46
src/components/StatusBadge.tsx
Normal file
46
src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Badge } from '@chakra-ui/react'
|
||||||
|
import type { SubmissionStatus } from '../types/challenge'
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: SubmissionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
|
||||||
|
const getColorPalette = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'accepted':
|
||||||
|
return 'green'
|
||||||
|
case 'needs_revision':
|
||||||
|
return 'red'
|
||||||
|
case 'in_progress':
|
||||||
|
return 'blue'
|
||||||
|
case 'pending':
|
||||||
|
return 'orange'
|
||||||
|
default:
|
||||||
|
return 'gray'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLabel = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'accepted':
|
||||||
|
return 'Принято'
|
||||||
|
case 'needs_revision':
|
||||||
|
return 'Доработка'
|
||||||
|
case 'in_progress':
|
||||||
|
return 'Проверяется'
|
||||||
|
case 'pending':
|
||||||
|
return 'Ожидает'
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge colorPalette={getColorPalette()} variant="subtle">
|
||||||
|
{getLabel()}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
12
src/components/ui/toaster.tsx
Normal file
12
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createToaster, Toaster as ChakraToaster } from '@chakra-ui/react'
|
||||||
|
|
||||||
|
export const toaster = createToaster({
|
||||||
|
placement: 'top-end',
|
||||||
|
duration: 3000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Toaster = () => {
|
||||||
|
return <ChakraToaster toaster={toaster} />
|
||||||
|
}
|
||||||
|
|
||||||
107
src/dashboard.tsx
Normal file
107
src/dashboard.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { Suspense } from 'react'
|
||||||
|
import { Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { Layout } from './components/Layout'
|
||||||
|
import { DashboardPage } from './pages/dashboard/DashboardPage'
|
||||||
|
import { TasksListPage } from './pages/tasks/TasksListPage'
|
||||||
|
import { TaskFormPage } from './pages/tasks/TaskFormPage'
|
||||||
|
import { ChainsListPage } from './pages/chains/ChainsListPage'
|
||||||
|
import { ChainFormPage } from './pages/chains/ChainFormPage'
|
||||||
|
import { UsersPage } from './pages/users/UsersPage'
|
||||||
|
import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
|
||||||
|
import { URLs } from './__data__/urls'
|
||||||
|
|
||||||
|
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<Layout>{children}</Layout>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Dashboard */}
|
||||||
|
<Route
|
||||||
|
path={URLs.dashboard}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<DashboardPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<Route
|
||||||
|
path={URLs.tasks}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<TasksListPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={URLs.taskNew}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<TaskFormPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={URLs.taskEditPath}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<TaskFormPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Chains */}
|
||||||
|
<Route
|
||||||
|
path={URLs.chains}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<ChainsListPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={URLs.chainNew}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<ChainFormPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={URLs.chainEditPath}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<ChainFormPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
|
<Route
|
||||||
|
path={URLs.users}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<UsersPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Submissions */}
|
||||||
|
<Route
|
||||||
|
path={URLs.submissions}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<SubmissionsPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
68
src/index.tsx
Normal file
68
src/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/* eslint-disable react/display-name */
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import i18next from 'i18next'
|
||||||
|
import { i18nextReactInitConfig } from '@brojs/cli'
|
||||||
|
|
||||||
|
import App from './app'
|
||||||
|
import { keycloak } from './__data__/kc'
|
||||||
|
import { isAuthLoopBlocked, recordAuthAttempt, clearAuthAttempts } from './utils/authLoopGuard'
|
||||||
|
import { createStore } from './__data__/store'
|
||||||
|
|
||||||
|
i18next.t = i18next.t.bind(i18next)
|
||||||
|
const i18nextPromise = i18nextReactInitConfig(i18next)
|
||||||
|
|
||||||
|
export default (props) => <App {...props} />
|
||||||
|
|
||||||
|
let rootElement: ReactDOM.Root
|
||||||
|
|
||||||
|
export const mount = async (Component, element = document.getElementById('app')) => {
|
||||||
|
let user = null
|
||||||
|
try {
|
||||||
|
if (isAuthLoopBlocked()) {
|
||||||
|
await i18nextPromise
|
||||||
|
rootElement = ReactDOM.createRoot(element)
|
||||||
|
rootElement.render(<button onClick={() => keycloak.login()} style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'red',
|
||||||
|
margin: 'auto'
|
||||||
|
}}>Login</button>)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAuthAttempt()
|
||||||
|
await keycloak.init({
|
||||||
|
onLoad: 'login-required'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userInfo = await keycloak.loadUserInfo()
|
||||||
|
|
||||||
|
if (userInfo && keycloak.tokenParsed) {
|
||||||
|
user = { ...userInfo, ...keycloak.tokenParsed }
|
||||||
|
} else {
|
||||||
|
console.error('No userInfo or tokenParsed', userInfo, keycloak.tokenParsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAuthAttempts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Keycloak:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = createStore({ user })
|
||||||
|
await i18nextPromise
|
||||||
|
|
||||||
|
rootElement = ReactDOM.createRoot(element)
|
||||||
|
rootElement.render(<Component store={store} />)
|
||||||
|
|
||||||
|
if(module.hot) {
|
||||||
|
module.hot.accept('./app', ()=> {
|
||||||
|
rootElement.render(<Component store={store} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unmount = () => {
|
||||||
|
rootElement.unmount()
|
||||||
|
}
|
||||||
320
src/pages/chains/ChainFormPage.tsx
Normal file
320
src/pages/chains/ChainFormPage.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Heading,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Field,
|
||||||
|
Badge,
|
||||||
|
IconButton,
|
||||||
|
Flex,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import {
|
||||||
|
useGetChainQuery,
|
||||||
|
useGetTasksQuery,
|
||||||
|
useCreateChainMutation,
|
||||||
|
useUpdateChainMutation,
|
||||||
|
} from '../../__data__/api/api'
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
import { toaster } from '../../components/ui/toaster'
|
||||||
|
import type { ChallengeTask } from '../../types/challenge'
|
||||||
|
|
||||||
|
export const ChainFormPage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isEdit = !!id
|
||||||
|
|
||||||
|
const { data: chain, isLoading: isLoadingChain, error: loadError } = useGetChainQuery(id!, {
|
||||||
|
skip: !id,
|
||||||
|
})
|
||||||
|
const { data: allTasks, isLoading: isLoadingTasks } = useGetTasksQuery()
|
||||||
|
const [createChain, { isLoading: isCreating }] = useCreateChainMutation()
|
||||||
|
const [updateChain, { isLoading: isUpdating }] = useUpdateChainMutation()
|
||||||
|
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chain) {
|
||||||
|
setName(chain.name)
|
||||||
|
setSelectedTasks(chain.tasks)
|
||||||
|
}
|
||||||
|
}, [chain])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
toaster.create({
|
||||||
|
title: 'Ошибка валидации',
|
||||||
|
description: 'Введите название цепочки',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTasks.length === 0) {
|
||||||
|
toaster.create({
|
||||||
|
title: 'Ошибка валидации',
|
||||||
|
description: 'Добавьте хотя бы одно задание',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskIds = selectedTasks.map((task) => task.id)
|
||||||
|
|
||||||
|
if (isEdit && id) {
|
||||||
|
await updateChain({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
tasks: taskIds,
|
||||||
|
},
|
||||||
|
}).unwrap()
|
||||||
|
toaster.create({
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Цепочка обновлена',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createChain({
|
||||||
|
name: name.trim(),
|
||||||
|
tasks: taskIds,
|
||||||
|
}).unwrap()
|
||||||
|
toaster.create({
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Цепочка создана',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
navigate(URLs.chains)
|
||||||
|
} catch (err: any) {
|
||||||
|
toaster.create({
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: err?.data?.error?.message || 'Не удалось сохранить цепочку',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddTask = (task: ChallengeTask) => {
|
||||||
|
if (!selectedTasks.find((t) => t.id === task.id)) {
|
||||||
|
setSelectedTasks([...selectedTasks, task])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveTask = (taskId: string) => {
|
||||||
|
setSelectedTasks(selectedTasks.filter((t) => t.id !== taskId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoveUp = (index: number) => {
|
||||||
|
if (index === 0) return
|
||||||
|
const newTasks = [...selectedTasks]
|
||||||
|
;[newTasks[index - 1], newTasks[index]] = [newTasks[index], newTasks[index - 1]]
|
||||||
|
setSelectedTasks(newTasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoveDown = (index: number) => {
|
||||||
|
if (index === selectedTasks.length - 1) return
|
||||||
|
const newTasks = [...selectedTasks]
|
||||||
|
;[newTasks[index], newTasks[index + 1]] = [newTasks[index + 1], newTasks[index]]
|
||||||
|
setSelectedTasks(newTasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && isLoadingChain) {
|
||||||
|
return <LoadingSpinner message="Загрузка цепочки..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && loadError) {
|
||||||
|
return <ErrorAlert message="Не удалось загрузить цепочку" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingTasks) {
|
||||||
|
return <LoadingSpinner message="Загрузка заданий..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allTasks) {
|
||||||
|
return <ErrorAlert message="Не удалось загрузить список заданий" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = isCreating || isUpdating
|
||||||
|
|
||||||
|
const availableTasks = allTasks.filter(
|
||||||
|
(task) =>
|
||||||
|
!selectedTasks.find((t) => t.id === task.id) &&
|
||||||
|
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading mb={6}>{isEdit ? 'Редактировать цепочку' : 'Создать цепочку'}</Heading>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
bg="white"
|
||||||
|
p={6}
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="sm"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
>
|
||||||
|
<VStack gap={6} align="stretch">
|
||||||
|
{/* Name */}
|
||||||
|
<Field.Root required>
|
||||||
|
<Field.Label>Название цепочки</Field.Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Введите название цепочки"
|
||||||
|
maxLength={255}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Selected Tasks */}
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={3}>
|
||||||
|
Задания в цепочке ({selectedTasks.length})
|
||||||
|
</Text>
|
||||||
|
{selectedTasks.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
p={6}
|
||||||
|
borderWidth="2px"
|
||||||
|
borderStyle="dashed"
|
||||||
|
borderColor="gray.200"
|
||||||
|
borderRadius="md"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Text color="gray.500">Добавьте задания из списка ниже</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<VStack gap={2} align="stretch">
|
||||||
|
{selectedTasks.map((task, index) => (
|
||||||
|
<Flex
|
||||||
|
key={task.id}
|
||||||
|
p={3}
|
||||||
|
bg="teal.50"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="teal.200"
|
||||||
|
borderRadius="md"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
>
|
||||||
|
<HStack gap={3} flex={1}>
|
||||||
|
<Badge colorPalette="teal" variant="solid">
|
||||||
|
#{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<Text fontWeight="medium">{task.title}</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={1}>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleMoveUp(index)}
|
||||||
|
disabled={index === 0 || isLoading}
|
||||||
|
aria-label="Move up"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleMoveDown(index)}
|
||||||
|
disabled={index === selectedTasks.length - 1 || isLoading}
|
||||||
|
aria-label="Move down"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="red"
|
||||||
|
onClick={() => handleRemoveTask(task.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</IconButton>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Available Tasks */}
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={3}>
|
||||||
|
Доступные задания
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск заданий..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
mb={3}
|
||||||
|
/>
|
||||||
|
{availableTasks.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
p={6}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
borderRadius="md"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<Text color="gray.500">
|
||||||
|
{allTasks.length === selectedTasks.length
|
||||||
|
? 'Все задания уже добавлены'
|
||||||
|
: 'Ничего не найдено'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<VStack gap={2} align="stretch" maxH="400px" overflowY="auto">
|
||||||
|
{availableTasks.map((task) => (
|
||||||
|
<Flex
|
||||||
|
key={task.id}
|
||||||
|
p={3}
|
||||||
|
bg="gray.50"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
borderRadius="md"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
cursor="pointer"
|
||||||
|
_hover={{ bg: 'gray.100' }}
|
||||||
|
onClick={() => handleAddTask(task)}
|
||||||
|
>
|
||||||
|
<Text>{task.title}</Text>
|
||||||
|
<Button size="sm" colorPalette="teal" variant="ghost">
|
||||||
|
+ Добавить
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<HStack gap={3} justify="flex-end">
|
||||||
|
<Button variant="outline" onClick={() => navigate(URLs.chains)} disabled={isLoading}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" colorPalette="teal" loading={isLoading}>
|
||||||
|
{isEdit ? 'Сохранить изменения' : 'Создать цепочку'}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
166
src/pages/chains/ChainsListPage.tsx
Normal file
166
src/pages/chains/ChainsListPage.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Heading,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Flex,
|
||||||
|
Input,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { useGetChainsQuery, useDeleteChainMutation } from '../../__data__/api/api'
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
import { EmptyState } from '../../components/EmptyState'
|
||||||
|
import { ConfirmDialog } from '../../components/ConfirmDialog'
|
||||||
|
import type { ChallengeChain } from '../../types/challenge'
|
||||||
|
import { toaster } from '../../components/ui/toaster'
|
||||||
|
|
||||||
|
export const ChainsListPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: chains, isLoading, error, refetch } = useGetChainsQuery()
|
||||||
|
const [deleteChain, { isLoading: isDeleting }] = useDeleteChainMutation()
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
|
||||||
|
|
||||||
|
const handleDeleteChain = async () => {
|
||||||
|
if (!chainToDelete) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteChain(chainToDelete.id).unwrap()
|
||||||
|
toaster.create({
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Цепочка удалена',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
setChainToDelete(null)
|
||||||
|
} catch (err) {
|
||||||
|
toaster.create({
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: 'Не удалось удалить цепочку',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="Загрузка цепочек..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !chains) {
|
||||||
|
return <ErrorAlert message="Не удалось загрузить список цепочек" onRetry={refetch} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredChains = chains.filter((chain) =>
|
||||||
|
chain.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Flex justify="space-between" align="center" mb={6}>
|
||||||
|
<Heading>Цепочки заданий</Heading>
|
||||||
|
<Button colorPalette="teal" onClick={() => navigate(URLs.chainNew)}>
|
||||||
|
+ Создать цепочку
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{chains.length > 0 && (
|
||||||
|
<Box mb={4}>
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по названию..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
maxW="400px"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredChains.length === 0 && chains.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Нет цепочек"
|
||||||
|
description="Создайте первую цепочку заданий"
|
||||||
|
actionLabel="Создать цепочку"
|
||||||
|
onAction={() => navigate(URLs.chainNew)}
|
||||||
|
/>
|
||||||
|
) : filteredChains.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Ничего не найдено"
|
||||||
|
description={`По запросу "${searchQuery}" ничего не найдено`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
||||||
|
<Table.Root size="sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeader>Название</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Количество заданий</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Дата создания</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{filteredChains.map((chain) => (
|
||||||
|
<Table.Row key={chain.id}>
|
||||||
|
<Table.Cell fontWeight="medium">{chain.name}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge colorPalette="teal" variant="subtle">
|
||||||
|
{chain.tasks.length} заданий
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{formatDate(chain.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">
|
||||||
|
<HStack gap={2} justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigate(URLs.chainEdit(chain.id))}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="red"
|
||||||
|
onClick={() => setChainToDelete(chain)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!chainToDelete}
|
||||||
|
onClose={() => setChainToDelete(null)}
|
||||||
|
onConfirm={handleDeleteChain}
|
||||||
|
title="Удалить цепочку"
|
||||||
|
message={`Вы уверены, что хотите удалить цепочку "${chainToDelete?.name}"? Это действие нельзя отменить.`}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
162
src/pages/dashboard/DashboardPage.tsx
Normal file
162
src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Heading, Grid, Text, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
|
||||||
|
import { useGetSystemStatsQuery } from '../../__data__/api/api'
|
||||||
|
import { StatCard } from '../../components/StatCard'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
|
||||||
|
export const DashboardPage: React.FC = () => {
|
||||||
|
const { data: stats, isLoading, error, refetch } = useGetSystemStatsQuery(undefined, {
|
||||||
|
pollingInterval: 10000, // Обновление каждые 10 секунд
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="Загрузка статистики..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !stats) {
|
||||||
|
return <ErrorAlert message="Не удалось загрузить статистику системы" onRetry={refetch} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptanceRate = stats.submissions.total > 0
|
||||||
|
? ((stats.submissions.accepted / stats.submissions.total) * 100).toFixed(1)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
const rejectionRate = stats.submissions.total > 0
|
||||||
|
? ((stats.submissions.rejected / stats.submissions.total) * 100).toFixed(1)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
const queueUtilization = stats.queue.maxConcurrency > 0
|
||||||
|
? ((stats.queue.currentlyProcessing / stats.queue.maxConcurrency) * 100).toFixed(0)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading mb={6}>Dashboard</Heading>
|
||||||
|
|
||||||
|
{/* Main Stats */}
|
||||||
|
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={8}>
|
||||||
|
<StatCard label="Всего пользователей" value={stats.users} colorScheme="blue" />
|
||||||
|
<StatCard label="Всего заданий" value={stats.tasks} colorScheme="teal" />
|
||||||
|
<StatCard label="Всего цепочек" value={stats.chains} colorScheme="purple" />
|
||||||
|
<StatCard label="Всего проверок" value={stats.submissions.total} colorScheme="orange" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Submissions Stats */}
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
Статистика проверок
|
||||||
|
</Heading>
|
||||||
|
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={6}>
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Принято
|
||||||
|
</Text>
|
||||||
|
<HStack>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||||
|
{stats.submissions.accepted}
|
||||||
|
</Text>
|
||||||
|
<Badge colorPalette="green">{acceptanceRate}%</Badge>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Отклонено
|
||||||
|
</Text>
|
||||||
|
<HStack>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="red.600">
|
||||||
|
{stats.submissions.rejected}
|
||||||
|
</Text>
|
||||||
|
<Badge colorPalette="red">{rejectionRate}%</Badge>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Ожидают
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="yellow.600">
|
||||||
|
{stats.submissions.pending}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
В процессе
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||||
|
{stats.submissions.inProgress}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Queue Stats */}
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
|
||||||
|
<Heading size="md" mb={4}>
|
||||||
|
Статус очереди
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={4}>
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
В обработке
|
||||||
|
</Text>
|
||||||
|
<HStack align="baseline">
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="teal.600">
|
||||||
|
{stats.queue.currentlyProcessing}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.500">
|
||||||
|
/ {stats.queue.maxConcurrency}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Ожидают в очереди
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||||
|
{stats.queue.waiting}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Всего в очереди
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||||
|
{stats.queue.queueLength}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600" mb={2}>
|
||||||
|
Загруженность очереди: {queueUtilization}%
|
||||||
|
</Text>
|
||||||
|
<Progress.Root value={Number(queueUtilization)} colorPalette="teal" size="sm" borderRadius="full">
|
||||||
|
<Progress.Track>
|
||||||
|
<Progress.Range />
|
||||||
|
</Progress.Track>
|
||||||
|
</Progress.Root>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Average Check Time */}
|
||||||
|
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||||
|
<Heading size="md" mb={2}>
|
||||||
|
Среднее время проверки
|
||||||
|
</Heading>
|
||||||
|
<Text fontSize="3xl" fontWeight="bold" color="purple.600">
|
||||||
|
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600" mt={2}>
|
||||||
|
Время от отправки решения до получения результата
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/index.ts
Normal file
4
src/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { lazy } from 'react'
|
||||||
|
|
||||||
|
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
||||||
|
|
||||||
2
src/pages/main/index.ts
Normal file
2
src/pages/main/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { MainPage as default } from './main'
|
||||||
|
|
||||||
11
src/pages/main/main.tsx
Normal file
11
src/pages/main/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const MainPage = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Главная страница проекта challenge-admin-pl</h1>
|
||||||
|
<p>Это базовая страница с React Router</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
340
src/pages/submissions/SubmissionsPage.tsx
Normal file
340
src/pages/submissions/SubmissionsPage.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Heading,
|
||||||
|
Table,
|
||||||
|
Input,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Select,
|
||||||
|
DialogRoot,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogActionTrigger,
|
||||||
|
createListCollection,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import { useGetAllSubmissionsQuery } from '../../__data__/api/api'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
import { EmptyState } from '../../components/EmptyState'
|
||||||
|
import { StatusBadge } from '../../components/StatusBadge'
|
||||||
|
import type { ChallengeSubmission, SubmissionStatus, ChallengeTask, ChallengeUser } from '../../types/challenge'
|
||||||
|
|
||||||
|
export const SubmissionsPage: React.FC = () => {
|
||||||
|
const { data: submissions, isLoading, error, refetch } = useGetAllSubmissionsQuery()
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all')
|
||||||
|
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="Загрузка попыток..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !submissions) {
|
||||||
|
return <ErrorAlert message="Не удалось загрузить список попыток" onRetry={refetch} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSubmissions = submissions.filter((submission) => {
|
||||||
|
const user = submission.user as ChallengeUser
|
||||||
|
const task = submission.task as ChallengeTask
|
||||||
|
|
||||||
|
const matchesSearch =
|
||||||
|
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
|
||||||
|
const matchesStatus = statusFilter === 'all' || submission.status === statusFilter
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCheckTime = (submission: ChallengeSubmission) => {
|
||||||
|
if (!submission.checkedAt) return '—'
|
||||||
|
const submitted = new Date(submission.submittedAt).getTime()
|
||||||
|
const checked = new Date(submission.checkedAt).getTime()
|
||||||
|
const diff = Math.round((checked - submitted) / 1000)
|
||||||
|
return `${diff} сек`
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions = createListCollection({
|
||||||
|
items: [
|
||||||
|
{ label: 'Все статусы', value: 'all' },
|
||||||
|
{ label: 'Принято', value: 'accepted' },
|
||||||
|
{ label: 'Доработка', value: 'needs_revision' },
|
||||||
|
{ label: 'Проверяется', value: 'in_progress' },
|
||||||
|
{ label: 'Ожидает', value: 'pending' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading mb={6}>Попытки решений</Heading>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
{submissions.length > 0 && (
|
||||||
|
<HStack mb={4} gap={4}>
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по пользователю или заданию..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
maxW="400px"
|
||||||
|
/>
|
||||||
|
<Select.Root
|
||||||
|
collection={statusOptions}
|
||||||
|
value={[statusFilter]}
|
||||||
|
onValueChange={(e) => setStatusFilter(e.value[0] as SubmissionStatus | 'all')}
|
||||||
|
maxW="200px"
|
||||||
|
>
|
||||||
|
<Select.Trigger>
|
||||||
|
<Select.ValueText placeholder="Статус" />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{statusOptions.items.map((option) => (
|
||||||
|
<Select.Item key={option.value} item={option}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredSubmissions.length === 0 && submissions.length === 0 ? (
|
||||||
|
<EmptyState title="Нет попыток" description="Попытки появятся после отправки решений" />
|
||||||
|
) : filteredSubmissions.length === 0 ? (
|
||||||
|
<EmptyState title="Ничего не найдено" description="Попробуйте изменить фильтры" />
|
||||||
|
) : (
|
||||||
|
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
||||||
|
<Table.Root size="sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeader>Пользователь</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Задание</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Статус</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Попытка</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Дата отправки</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Время проверки</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{filteredSubmissions.map((submission) => {
|
||||||
|
const user = submission.user as ChallengeUser
|
||||||
|
const task = submission.task as ChallengeTask
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Row key={submission.id}>
|
||||||
|
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
|
||||||
|
<Table.Cell>{task.title}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<StatusBadge status={submission.status} />
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
#{submission.attemptNumber}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{formatDate(submission.submittedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{getCheckTime(submission)}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="teal"
|
||||||
|
onClick={() => setSelectedSubmission(submission)}
|
||||||
|
>
|
||||||
|
Детали
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submission Details Modal */}
|
||||||
|
<SubmissionDetailsModal
|
||||||
|
submission={selectedSubmission}
|
||||||
|
isOpen={!!selectedSubmission}
|
||||||
|
onClose={() => setSelectedSubmission(null)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmissionDetailsModalProps {
|
||||||
|
submission: ChallengeSubmission | null
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||||
|
submission,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
if (!submission) return null
|
||||||
|
|
||||||
|
const user = submission.user as ChallengeUser
|
||||||
|
const task = submission.task as ChallengeTask
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCheckTime = () => {
|
||||||
|
if (!submission.checkedAt) return null
|
||||||
|
const submitted = new Date(submission.submittedAt).getTime()
|
||||||
|
const checked = new Date(submission.checkedAt).getTime()
|
||||||
|
return ((checked - submitted) / 1000).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Детали попытки #{submission.attemptNumber}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<VStack gap={6} align="stretch">
|
||||||
|
{/* Meta */}
|
||||||
|
<Box>
|
||||||
|
<HStack mb={4} justify="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600" mb={1}>
|
||||||
|
Пользователь
|
||||||
|
</Text>
|
||||||
|
<Text fontWeight="bold">{user.nickname}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600" mb={1}>
|
||||||
|
Статус
|
||||||
|
</Text>
|
||||||
|
<StatusBadge status={submission.status} />
|
||||||
|
</Box>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack align="stretch" gap={2}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
<strong>Отправлено:</strong> {formatDate(submission.submittedAt)}
|
||||||
|
</Text>
|
||||||
|
{submission.checkedAt && (
|
||||||
|
<>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
<strong>Проверено:</strong> {formatDate(submission.checkedAt)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
<strong>Время проверки:</strong> {getCheckTime()} сек
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Task */}
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={2}>
|
||||||
|
Задание: {task.title}
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg="gray.50"
|
||||||
|
borderRadius="md"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
maxH="200px"
|
||||||
|
overflowY="auto"
|
||||||
|
>
|
||||||
|
<ReactMarkdown>{task.description}</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Solution */}
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={2}>
|
||||||
|
Решение пользователя:
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg="blue.50"
|
||||||
|
borderRadius="md"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="blue.200"
|
||||||
|
maxH="300px"
|
||||||
|
overflowY="auto"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontFamily="monospace"
|
||||||
|
fontSize="sm"
|
||||||
|
whiteSpace="pre-wrap"
|
||||||
|
wordBreak="break-word"
|
||||||
|
>
|
||||||
|
{submission.result}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
{submission.feedback && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={2}>
|
||||||
|
Обратная связь от LLM:
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
bg={submission.status === 'accepted' ? 'green.50' : 'red.50'}
|
||||||
|
borderRadius="md"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor={submission.status === 'accepted' ? 'green.200' : 'red.200'}
|
||||||
|
>
|
||||||
|
<Text>{submission.feedback}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActionTrigger asChild>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</DialogActionTrigger>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
244
src/pages/tasks/TaskFormPage.tsx
Normal file
244
src/pages/tasks/TaskFormPage.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Heading,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Flex,
|
||||||
|
Stack,
|
||||||
|
Field,
|
||||||
|
Tabs,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import {
|
||||||
|
useGetTaskQuery,
|
||||||
|
useCreateTaskMutation,
|
||||||
|
useUpdateTaskMutation,
|
||||||
|
} from '../../__data__/api/api'
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
import { toaster } from '../../components/ui/toaster'
|
||||||
|
|
||||||
|
export const TaskFormPage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isEdit = !!id
|
||||||
|
|
||||||
|
const { data: task, isLoading: isLoadingTask, error: loadError } = useGetTaskQuery(id!, {
|
||||||
|
skip: !id,
|
||||||
|
})
|
||||||
|
const [createTask, { isLoading: isCreating }] = useCreateTaskMutation()
|
||||||
|
const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation()
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [hiddenInstructions, setHiddenInstructions] = useState('')
|
||||||
|
const [showDescPreview, setShowDescPreview] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task) {
|
||||||
|
setTitle(task.title)
|
||||||
|
setDescription(task.description)
|
||||||
|
setHiddenInstructions(task.hiddenInstructions || '')
|
||||||
|
}
|
||||||
|
}, [task])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!title.trim() || !description.trim()) {
|
||||||
|
toaster.create({
|
||||||
|
title: 'Ошибка валидации',
|
||||||
|
description: 'Заполните обязательные поля',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && id) {
|
||||||
|
await updateTask({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
hiddenInstructions: hiddenInstructions.trim() || undefined,
|
||||||
|
},
|
||||||
|
}).unwrap()
|
||||||
|
toaster.create({
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Задание обновлено',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createTask({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
hiddenInstructions: hiddenInstructions.trim() || undefined,
|
||||||
|
}).unwrap()
|
||||||
|
toaster.create({
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Задание создано',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
navigate(URLs.tasks)
|
||||||
|
} catch (err: any) {
|
||||||
|
toaster.create({
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: err?.data?.error?.message || 'Не удалось сохранить задание',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && isLoadingTask) {
|
||||||
|
return <LoadingSpinner message="Загрузка задания..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && loadError) {
|
||||||
|
return <ErrorAlert message="Не удалось загрузить задание" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = isCreating || isUpdating
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading mb={6}>{isEdit ? 'Редактировать задание' : 'Создать задание'}</Heading>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
bg="white"
|
||||||
|
p={6}
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="sm"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
>
|
||||||
|
<VStack gap={6} align="stretch">
|
||||||
|
{/* Title */}
|
||||||
|
<Field.Root required>
|
||||||
|
<Field.Label>Название задания</Field.Label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Введите название задания"
|
||||||
|
maxLength={255}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Field.HelperText>Максимум 255 символов</Field.HelperText>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Description with Markdown */}
|
||||||
|
<Field.Root required>
|
||||||
|
<Field.Label>Описание (Markdown)</Field.Label>
|
||||||
|
<Tabs.Root
|
||||||
|
value={showDescPreview ? 'preview' : 'editor'}
|
||||||
|
onValueChange={(e) => setShowDescPreview(e.value === 'preview')}
|
||||||
|
>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Trigger value="editor">Редактор</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="preview">Превью</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content value="editor" pt={4}>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="# Заголовок задания Описание задания в формате Markdown..."
|
||||||
|
rows={15}
|
||||||
|
fontFamily="monospace"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="preview" pt={4}>
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
borderRadius="md"
|
||||||
|
minH="300px"
|
||||||
|
bg="gray.50"
|
||||||
|
>
|
||||||
|
{description ? (
|
||||||
|
<Box className="markdown-preview">
|
||||||
|
<ReactMarkdown>{description}</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text color="gray.400" fontStyle="italic">
|
||||||
|
Предпросмотр появится здесь...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
<Field.HelperText>Используйте Markdown для форматирования текста</Field.HelperText>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Hidden Instructions */}
|
||||||
|
<Field.Root>
|
||||||
|
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">
|
||||||
|
<HStack mb={2}>
|
||||||
|
<Text fontWeight="bold" color="purple.800">
|
||||||
|
🔒 Скрытые инструкции для LLM
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="purple.700" mb={3}>
|
||||||
|
Эти инструкции будут переданы LLM при проверке решений студентов. Студенты их не
|
||||||
|
увидят.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
value={hiddenInstructions}
|
||||||
|
onChange={(e) => setHiddenInstructions(e.target.value)}
|
||||||
|
placeholder="Например: Проверь, что сложность алгоритма O(n log n). Код должен обрабатывать edge cases..."
|
||||||
|
rows={6}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Field.HelperText>
|
||||||
|
Опционально. Используйте для тонкой настройки проверки LLM.
|
||||||
|
</Field.HelperText>
|
||||||
|
</Box>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Meta info for edit mode */}
|
||||||
|
{isEdit && task && (
|
||||||
|
<Box p={4} bg="gray.50" borderRadius="md">
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
<strong>Создано:</strong>{' '}
|
||||||
|
{new Date(task.createdAt).toLocaleString('ru-RU')}
|
||||||
|
</Text>
|
||||||
|
{task.creator && (
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
<strong>Автор:</strong> {task.creator.preferred_username}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{task.updatedAt !== task.createdAt && (
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
<strong>Обновлено:</strong>{' '}
|
||||||
|
{new Date(task.updatedAt).toLocaleString('ru-RU')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<HStack gap={3} justify="flex-end">
|
||||||
|
<Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" colorPalette="teal" loading={isLoading}>
|
||||||
|
{isEdit ? 'Сохранить изменения' : 'Создать задание'}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
180
src/pages/tasks/TasksListPage.tsx
Normal file
180
src/pages/tasks/TasksListPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Heading,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Flex,
|
||||||
|
Input,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Badge,
|
||||||
|
createListCollection,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { useGetTasksQuery, useDeleteTaskMutation } from '../../__data__/api/api'
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
import { EmptyState } from '../../components/EmptyState'
|
||||||
|
import { ConfirmDialog } from '../../components/ConfirmDialog'
|
||||||
|
import type { ChallengeTask } from '../../types/challenge'
|
||||||
|
import { toaster } from '../../components/ui/toaster'
|
||||||
|
|
||||||
|
export const TasksListPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: tasks, isLoading, error, refetch } = useGetTasksQuery()
|
||||||
|
const [deleteTask, { isLoading: isDeleting }] = useDeleteTaskMutation()
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [taskToDelete, setTaskToDelete] = useState<ChallengeTask | null>(null)
|
||||||
|
|
||||||
|
const handleDeleteTask = async () => {
|
||||||
|
if (!taskToDelete) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTask(taskToDelete.id).unwrap()
|
||||||
|
toaster.create({
|
||||||
|
title: 'Успешно',
|
||||||
|
description: 'Задание удалено',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
setTaskToDelete(null)
|
||||||
|
} catch (err) {
|
||||||
|
toaster.create({
|
||||||
|
title: 'Ошибка',
|
||||||
|
description: 'Не удалось удалить задание',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="Загрузка заданий..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !tasks) {
|
||||||
|
return <ErrorAlert message="Не удалось загрузить список заданий" onRetry={refetch} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredTasks = tasks.filter((task) =>
|
||||||
|
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Flex justify="space-between" align="center" mb={6}>
|
||||||
|
<Heading>Задания</Heading>
|
||||||
|
<Button colorPalette="teal" onClick={() => navigate(URLs.taskNew)}>
|
||||||
|
+ Создать задание
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<Box mb={4}>
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по названию..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
maxW="400px"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredTasks.length === 0 && tasks.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Нет заданий"
|
||||||
|
description="Создайте первое задание для начала работы"
|
||||||
|
actionLabel="Создать задание"
|
||||||
|
onAction={() => navigate(URLs.taskNew)}
|
||||||
|
/>
|
||||||
|
) : filteredTasks.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Ничего не найдено"
|
||||||
|
description={`По запросу "${searchQuery}" ничего не найдено`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
||||||
|
<Table.Root size="sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeader>Название</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Создатель</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Дата создания</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Скрытые инструкции</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{filteredTasks.map((task) => (
|
||||||
|
<Table.Row key={task.id}>
|
||||||
|
<Table.Cell fontWeight="medium">{task.title}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{task.creator?.preferred_username || 'N/A'}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{formatDate(task.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{task.hiddenInstructions ? (
|
||||||
|
<Badge colorPalette="purple" variant="subtle">
|
||||||
|
🔒 Есть
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="sm" color="gray.400">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">
|
||||||
|
<HStack gap={2} justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigate(URLs.taskEdit(task.id))}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="red"
|
||||||
|
onClick={() => setTaskToDelete(task)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!taskToDelete}
|
||||||
|
onClose={() => setTaskToDelete(null)}
|
||||||
|
onConfirm={handleDeleteTask}
|
||||||
|
title="Удалить задание"
|
||||||
|
message={`Вы уверены, что хотите удалить задание "${taskToDelete?.title}"? Это действие нельзя отменить.`}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
281
src/pages/users/UsersPage.tsx
Normal file
281
src/pages/users/UsersPage.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Heading,
|
||||||
|
Table,
|
||||||
|
Input,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
DialogRoot,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogBody,
|
||||||
|
DialogFooter,
|
||||||
|
DialogActionTrigger,
|
||||||
|
Grid,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Badge,
|
||||||
|
Progress,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { useGetUsersQuery, useGetUserStatsQuery } from '../../__data__/api/api'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
import { EmptyState } from '../../components/EmptyState'
|
||||||
|
import type { ChallengeUser } from '../../types/challenge'
|
||||||
|
|
||||||
|
export const UsersPage: React.FC = () => {
|
||||||
|
const { data: users, isLoading, error, refetch } = useGetUsersQuery()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="Загрузка пользователей..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !users) {
|
||||||
|
return <ErrorAlert message="Не удалось загрузить список пользователей" onRetry={refetch} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredUsers = users.filter((user) =>
|
||||||
|
user.nickname.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading mb={6}>Пользователи</Heading>
|
||||||
|
|
||||||
|
{users.length > 0 && (
|
||||||
|
<Box mb={4}>
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по nickname..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
maxW="400px"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredUsers.length === 0 && users.length === 0 ? (
|
||||||
|
<EmptyState title="Нет пользователей" description="Пользователи появятся после регистрации" />
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Ничего не найдено"
|
||||||
|
description={`По запросу "${searchQuery}" ничего не найдено`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
||||||
|
<Table.Root size="sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeader>Nickname</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>ID</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Дата регистрации</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<Table.Row key={user.id}>
|
||||||
|
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="xs" fontFamily="monospace" color="gray.600">
|
||||||
|
{user.id}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{formatDate(user.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="teal"
|
||||||
|
onClick={() => setSelectedUserId(user.id)}
|
||||||
|
>
|
||||||
|
Статистика
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Stats Modal */}
|
||||||
|
<UserStatsModal
|
||||||
|
userId={selectedUserId}
|
||||||
|
isOpen={!!selectedUserId}
|
||||||
|
onClose={() => setSelectedUserId(null)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserStatsModalProps {
|
||||||
|
userId: string | null
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
|
||||||
|
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
|
||||||
|
skip: !userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Статистика пользователя</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner message="Загрузка статистики..." />
|
||||||
|
) : !stats ? (
|
||||||
|
<Text color="gray.600">Нет данных</Text>
|
||||||
|
) : (
|
||||||
|
<VStack gap={6} align="stretch">
|
||||||
|
{/* Overview */}
|
||||||
|
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Выполнено
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||||
|
{stats.completedTasks}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Всего попыток
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||||
|
{stats.totalSubmissions}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
В процессе
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||||
|
{stats.inProgressTasks}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
Требует доработки
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="red.600">
|
||||||
|
{stats.needsRevisionTasks}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Chains Progress */}
|
||||||
|
{stats.chainStats.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={3}>
|
||||||
|
Прогресс по цепочкам
|
||||||
|
</Text>
|
||||||
|
<VStack gap={3} align="stretch">
|
||||||
|
{stats.chainStats.map((chain) => (
|
||||||
|
<Box key={chain.chainId}>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{chain.chainName}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{chain.completedTasks} / {chain.totalTasks}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Progress value={chain.progress} colorPalette="teal" size="sm" />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task Stats */}
|
||||||
|
{stats.taskStats.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={3}>
|
||||||
|
Задания
|
||||||
|
</Text>
|
||||||
|
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
|
||||||
|
{stats.taskStats.map((taskStat) => (
|
||||||
|
<Box
|
||||||
|
key={taskStat.taskId}
|
||||||
|
p={3}
|
||||||
|
bg="gray.50"
|
||||||
|
borderRadius="md"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{taskStat.taskTitle}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
colorPalette={
|
||||||
|
taskStat.status === 'completed'
|
||||||
|
? 'green'
|
||||||
|
: taskStat.status === 'needs_revision'
|
||||||
|
? 'red'
|
||||||
|
: 'gray'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{taskStat.status === 'completed'
|
||||||
|
? 'Завершено'
|
||||||
|
: taskStat.status === 'needs_revision'
|
||||||
|
? 'Доработка'
|
||||||
|
: taskStat.status === 'in_progress'
|
||||||
|
? 'В процессе'
|
||||||
|
: 'Не начато'}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color="gray.600">
|
||||||
|
Попыток: {taskStat.totalAttempts}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Average Check Time */}
|
||||||
|
<Box p={3} bg="purple.50" borderRadius="md">
|
||||||
|
<Text fontSize="sm" color="gray.700" mb={1}>
|
||||||
|
Среднее время проверки
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color="purple.700">
|
||||||
|
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogActionTrigger asChild>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</DialogActionTrigger>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
79
src/theme.tsx
Normal file
79
src/theme.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ChakraProvider as ChacraProv, createSystem, defaultConfig } from '@chakra-ui/react'
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
|
const ChacraProvider: React.ElementType = ChacraProv
|
||||||
|
|
||||||
|
const system = createSystem(defaultConfig, {
|
||||||
|
globalCss: {
|
||||||
|
body: {
|
||||||
|
colorPalette: 'teal',
|
||||||
|
},
|
||||||
|
'.markdown-preview': {
|
||||||
|
'& h1': {
|
||||||
|
fontSize: '2xl',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: '4',
|
||||||
|
marginBottom: '2',
|
||||||
|
},
|
||||||
|
'& h2': {
|
||||||
|
fontSize: 'xl',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: '3',
|
||||||
|
marginBottom: '2',
|
||||||
|
},
|
||||||
|
'& h3': {
|
||||||
|
fontSize: 'lg',
|
||||||
|
fontWeight: 'semibold',
|
||||||
|
marginTop: '2',
|
||||||
|
marginBottom: '1',
|
||||||
|
},
|
||||||
|
'& p': {
|
||||||
|
marginBottom: '2',
|
||||||
|
},
|
||||||
|
'& ul, & ol': {
|
||||||
|
marginLeft: '4',
|
||||||
|
marginBottom: '2',
|
||||||
|
},
|
||||||
|
'& code': {
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
padding: '0.125rem 0.25rem',
|
||||||
|
borderRadius: 'sm',
|
||||||
|
fontSize: 'sm',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
'& pre': {
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
padding: '3',
|
||||||
|
borderRadius: 'md',
|
||||||
|
marginBottom: '2',
|
||||||
|
overflowX: 'auto',
|
||||||
|
},
|
||||||
|
'& pre code': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
padding: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
tokens: {
|
||||||
|
fonts: {
|
||||||
|
body: { value: 'var(--font-outfit)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
semanticTokens: {
|
||||||
|
radii: {
|
||||||
|
l1: { value: '0.5rem' },
|
||||||
|
l2: { value: '0.75rem' },
|
||||||
|
l3: { value: '1rem' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Provider = (props: PropsWithChildren) => (
|
||||||
|
<ChacraProvider value={system}>
|
||||||
|
{props.children}
|
||||||
|
</ChacraProvider>
|
||||||
|
)
|
||||||
|
|
||||||
141
src/types/challenge.ts
Normal file
141
src/types/challenge.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Challenge Service Types
|
||||||
|
|
||||||
|
export interface ChallengeUser {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
nickname: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeTask {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string // Markdown
|
||||||
|
hiddenInstructions?: string // Только для преподавателей
|
||||||
|
creator?: {
|
||||||
|
sub: string
|
||||||
|
preferred_username: string
|
||||||
|
email?: string
|
||||||
|
} // Только для преподавателей
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeChain {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tasks: ChallengeTask[] // Populated
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision'
|
||||||
|
|
||||||
|
export interface ChallengeSubmission {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
user: ChallengeUser | string
|
||||||
|
task: ChallengeTask | string
|
||||||
|
result: string
|
||||||
|
status: SubmissionStatus
|
||||||
|
queueId?: string
|
||||||
|
feedback?: string
|
||||||
|
submittedAt: string
|
||||||
|
checkedAt?: string
|
||||||
|
attemptNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found'
|
||||||
|
|
||||||
|
export interface QueueStatus {
|
||||||
|
status: QueueStatusType
|
||||||
|
submission?: ChallengeSubmission & { task: ChallengeTask }
|
||||||
|
error?: string
|
||||||
|
position?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStats {
|
||||||
|
taskId: string
|
||||||
|
taskTitle: string
|
||||||
|
attempts: Array<{
|
||||||
|
attemptNumber: number
|
||||||
|
status: SubmissionStatus
|
||||||
|
submittedAt: string
|
||||||
|
checkedAt?: string
|
||||||
|
feedback?: string
|
||||||
|
}>
|
||||||
|
totalAttempts: number
|
||||||
|
status: 'not_attempted' | 'pending' | 'in_progress' | 'completed' | 'needs_revision'
|
||||||
|
lastAttemptAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainStats {
|
||||||
|
chainId: string
|
||||||
|
chainName: string
|
||||||
|
totalTasks: number
|
||||||
|
completedTasks: number
|
||||||
|
progress: number // 0-100
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
totalTasksAttempted: number
|
||||||
|
completedTasks: number
|
||||||
|
inProgressTasks: number
|
||||||
|
needsRevisionTasks: number
|
||||||
|
totalSubmissions: number
|
||||||
|
averageCheckTimeMs: number
|
||||||
|
taskStats: TaskStats[]
|
||||||
|
chainStats: ChainStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStats {
|
||||||
|
users: number
|
||||||
|
tasks: number
|
||||||
|
chains: number
|
||||||
|
submissions: {
|
||||||
|
total: number
|
||||||
|
accepted: number
|
||||||
|
rejected: number
|
||||||
|
pending: number
|
||||||
|
inProgress: number
|
||||||
|
}
|
||||||
|
averageCheckTimeMs: number
|
||||||
|
queue: {
|
||||||
|
queueLength: number
|
||||||
|
waiting: number
|
||||||
|
inProgress: number
|
||||||
|
maxConcurrency: number
|
||||||
|
currentlyProcessing: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Request/Response types
|
||||||
|
export interface APIResponse<T> {
|
||||||
|
error: any
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskRequest {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
hiddenInstructions?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskRequest {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
hiddenInstructions?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChainRequest {
|
||||||
|
name: string
|
||||||
|
tasks: string[] // Array of task IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateChainRequest {
|
||||||
|
name?: string
|
||||||
|
tasks?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
59
src/utils/authLoopGuard.ts
Normal file
59
src/utils/authLoopGuard.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const STORAGE_KEY = 'auth.loop.attempts'
|
||||||
|
const DEFAULT_WINDOW_MS = 2_000
|
||||||
|
const DEFAULT_THRESHOLD = 2
|
||||||
|
|
||||||
|
const readAttempts = (): number[] => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed)) return parsed.filter((n) => typeof n === 'number')
|
||||||
|
return []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeAttempts = (attempts: number[]) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(attempts))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recordAuthAttempt = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
const attempts = readAttempts()
|
||||||
|
const updated = [...attempts, now].slice(-10)
|
||||||
|
writeAttempts(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearAuthAttempts = () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRecentAttempts = (windowMs: number = DEFAULT_WINDOW_MS): number[] => {
|
||||||
|
const now = Date.now()
|
||||||
|
return readAttempts().filter((ts) => now - ts <= windowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAuthLoopBlocked = (
|
||||||
|
windowMs: number = DEFAULT_WINDOW_MS,
|
||||||
|
threshold: number = DEFAULT_THRESHOLD,
|
||||||
|
): boolean => {
|
||||||
|
return getRecentAttempts(windowMs).length >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_LOOP_GUARD = {
|
||||||
|
recordAuthAttempt,
|
||||||
|
clearAuthAttempts,
|
||||||
|
getRecentAttempts,
|
||||||
|
isAuthLoopBlocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
173
stubs/api/README.md
Normal file
173
stubs/api/README.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Challenge Admin API Stubs
|
||||||
|
|
||||||
|
Стабовый API сервер для разработки и тестирования админской панели Challenge Service.
|
||||||
|
|
||||||
|
## 📁 Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
stubs/api/
|
||||||
|
├── data/ # JSON файлы с тестовыми данными
|
||||||
|
│ ├── tasks.json # Задания (5 шт.)
|
||||||
|
│ ├── chains.json # Цепочки (3 шт.)
|
||||||
|
│ ├── users.json # Пользователи (8 шт.)
|
||||||
|
│ ├── submissions.json # Попытки (8 шт.)
|
||||||
|
│ └── stats.json # Системная статистика
|
||||||
|
├── index.js # API роуты
|
||||||
|
└── README.md # Эта документация
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Реализованные endpoints
|
||||||
|
|
||||||
|
### Tasks (Задания)
|
||||||
|
- `GET /api/challenge/tasks` - список всех заданий
|
||||||
|
- `GET /api/challenge/task/:id` - одно задание
|
||||||
|
- `POST /api/challenge/task` - создать задание
|
||||||
|
- `PUT /api/challenge/task/:id` - обновить задание
|
||||||
|
- `DELETE /api/challenge/task/:id` - удалить задание
|
||||||
|
|
||||||
|
### Chains (Цепочки)
|
||||||
|
- `GET /api/challenge/chains` - список всех цепочек
|
||||||
|
- `GET /api/challenge/chain/:id` - одна цепочка
|
||||||
|
- `POST /api/challenge/chain` - создать цепочку
|
||||||
|
- `PUT /api/challenge/chain/:id` - обновить цепочку
|
||||||
|
- `DELETE /api/challenge/chain/:id` - удалить цепочку
|
||||||
|
|
||||||
|
### Users (Пользователи)
|
||||||
|
- `GET /api/challenge/users` - список всех пользователей
|
||||||
|
|
||||||
|
### Statistics (Статистика)
|
||||||
|
- `GET /api/challenge/stats` - общая системная статистика
|
||||||
|
- `GET /api/challenge/user/:userId/stats` - статистика пользователя (генерируется динамически)
|
||||||
|
|
||||||
|
### Submissions (Попытки)
|
||||||
|
- `GET /api/challenge/submissions` - все попытки
|
||||||
|
- `GET /api/challenge/user/:userId/submissions?taskId=xxx` - попытки пользователя (с опциональной фильтрацией по заданию)
|
||||||
|
|
||||||
|
## 📝 Формат ответов
|
||||||
|
|
||||||
|
Все ответы возвращаются в формате:
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": <данные>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибка
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Описание ошибки"
|
||||||
|
},
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💾 In-memory хранилище
|
||||||
|
|
||||||
|
Стабовый сервер использует **in-memory хранилище**:
|
||||||
|
- JSON файлы загружаются в память при первом запросе
|
||||||
|
- Все изменения (CREATE/UPDATE/DELETE) сохраняются только в памяти
|
||||||
|
- При перезапуске сервера все изменения сбрасываются
|
||||||
|
- Оригинальные JSON файлы не изменяются
|
||||||
|
|
||||||
|
## 🎯 Особенности
|
||||||
|
|
||||||
|
### 1. Автоматическое обновление статистики
|
||||||
|
При создании/удалении задания или цепочки автоматически обновляется системная статистика.
|
||||||
|
|
||||||
|
### 2. Динамическая генерация статистики пользователей
|
||||||
|
Endpoint `/api/challenge/user/:userId/stats` генерирует статистику на лету на основе:
|
||||||
|
- Попыток пользователя (submissions)
|
||||||
|
- Доступных цепочек
|
||||||
|
- Статуса заданий
|
||||||
|
|
||||||
|
### 3. Populate для цепочек
|
||||||
|
При создании/обновлении цепочки задания автоматически populated из списка заданий.
|
||||||
|
|
||||||
|
### 4. Валидация
|
||||||
|
Стабовый сервер включает базовую валидацию:
|
||||||
|
- Проверка обязательных полей
|
||||||
|
- Проверка существования ресурсов
|
||||||
|
- Возврат корректных HTTP статусов (404, 400)
|
||||||
|
|
||||||
|
## 📊 Тестовые данные
|
||||||
|
|
||||||
|
### Задания (5 шт.)
|
||||||
|
1. **Реализовать сортировку массива** - с hiddenInstructions о сложности O(n log n)
|
||||||
|
2. **Создать REST API endpoint** - с требованием пагинации
|
||||||
|
3. **Компонент React формы** - с валидацией
|
||||||
|
4. **SQL запрос с JOIN** - без hiddenInstructions
|
||||||
|
5. **Валидация формы** - с проверкой edge cases
|
||||||
|
|
||||||
|
### Цепочки (3 шт.)
|
||||||
|
1. **Основы JavaScript** - 2 задания
|
||||||
|
2. **React разработка** - 1 задание
|
||||||
|
3. **Backend разработка** - 2 задания
|
||||||
|
|
||||||
|
### Пользователи (8 шт.)
|
||||||
|
- alex_student, maria_dev, ivan_coder, olga_js
|
||||||
|
- dmitry_react, anna_frontend, sergey_backend, elena_fullstack
|
||||||
|
|
||||||
|
### Попытки (8 шт.)
|
||||||
|
Различные статусы:
|
||||||
|
- **accepted** (5) - принятые решения
|
||||||
|
- **needs_revision** (3) - требующие доработки
|
||||||
|
- Включают реалистичный feedback от LLM
|
||||||
|
|
||||||
|
## 🔄 Примеры запросов
|
||||||
|
|
||||||
|
### Создать задание
|
||||||
|
```bash
|
||||||
|
POST /api/challenge/task
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "Новое задание",
|
||||||
|
"description": "# Описание\n\nТекст задания...",
|
||||||
|
"hiddenInstructions": "Проверь алгоритм..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Создать цепочку
|
||||||
|
```bash
|
||||||
|
POST /api/challenge/chain
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Моя цепочка",
|
||||||
|
"tasks": ["507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получить статистику пользователя
|
||||||
|
```bash
|
||||||
|
GET /api/challenge/user/user001/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ будет содержать динамически вычисленную статистику на основе всех попыток пользователя.
|
||||||
|
|
||||||
|
## ⚙️ Настройка задержки
|
||||||
|
|
||||||
|
По умолчанию все запросы имеют задержку 300ms для имитации сетевых запросов. Изменить можно в `index.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const timer = (time = 300) => (req, res, next) => setTimeout(next, time);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Использование
|
||||||
|
|
||||||
|
Стабы автоматически подключаются при запуске dev сервера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервер будет доступен на `http://localhost:8099`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Примечание:** Этот стабовый API предназначен только для разработки. В production окружении используйте реальный Challenge Service API.
|
||||||
|
|
||||||
70
stubs/api/data/chains.json
Normal file
70
stubs/api/data/chains.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"_id": "607f1f77bcf86cd799439021",
|
||||||
|
"id": "607f1f77bcf86cd799439021",
|
||||||
|
"name": "Основы JavaScript",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439011",
|
||||||
|
"id": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Реализовать сортировку массива",
|
||||||
|
"description": "# Задание: Сортировка массива\n\nНапишите функцию `sortArray(arr)`, которая сортирует массив чисел по возрастанию.",
|
||||||
|
"createdAt": "2024-11-01T10:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-01T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439015",
|
||||||
|
"id": "507f1f77bcf86cd799439015",
|
||||||
|
"title": "Валидация формы",
|
||||||
|
"description": "# Задание: Валидация email\n\nНапишите функцию для валидации email адреса.",
|
||||||
|
"createdAt": "2024-11-05T11:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-05T11:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdAt": "2024-11-01T09:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-05T12:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "607f1f77bcf86cd799439022",
|
||||||
|
"id": "607f1f77bcf86cd799439022",
|
||||||
|
"name": "React разработка",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439013",
|
||||||
|
"id": "507f1f77bcf86cd799439013",
|
||||||
|
"title": "Компонент React формы",
|
||||||
|
"description": "# Задание: Форма регистрации\n\nСоздайте компонент React для формы регистрации.",
|
||||||
|
"createdAt": "2024-11-03T09:15:00.000Z",
|
||||||
|
"updatedAt": "2024-11-03T09:15:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdAt": "2024-11-03T08:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-03T09:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "607f1f77bcf86cd799439023",
|
||||||
|
"id": "607f1f77bcf86cd799439023",
|
||||||
|
"name": "Backend разработка",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439012",
|
||||||
|
"id": "507f1f77bcf86cd799439012",
|
||||||
|
"title": "Создать REST API endpoint",
|
||||||
|
"description": "# Задание: REST API для пользователей\n\nСоздайте REST API endpoint для получения списка пользователей.",
|
||||||
|
"createdAt": "2024-11-02T12:30:00.000Z",
|
||||||
|
"updatedAt": "2024-11-02T12:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439014",
|
||||||
|
"id": "507f1f77bcf86cd799439014",
|
||||||
|
"title": "SQL запрос с JOIN",
|
||||||
|
"description": "# Задание: SQL запрос\n\nНапишите SQL запрос для выборки всех заказов пользователя.",
|
||||||
|
"createdAt": "2024-11-04T14:20:00.000Z",
|
||||||
|
"updatedAt": "2024-11-04T14:20:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdAt": "2024-11-02T11:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-04T15:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
21
stubs/api/data/stats.json
Normal file
21
stubs/api/data/stats.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"users": 8,
|
||||||
|
"tasks": 5,
|
||||||
|
"chains": 3,
|
||||||
|
"submissions": {
|
||||||
|
"total": 8,
|
||||||
|
"accepted": 5,
|
||||||
|
"rejected": 3,
|
||||||
|
"pending": 0,
|
||||||
|
"inProgress": 0
|
||||||
|
},
|
||||||
|
"averageCheckTimeMs": 3275,
|
||||||
|
"queue": {
|
||||||
|
"queueLength": 0,
|
||||||
|
"waiting": 0,
|
||||||
|
"inProgress": 0,
|
||||||
|
"maxConcurrency": 5,
|
||||||
|
"currentlyProcessing": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
203
stubs/api/data/submissions.json
Normal file
203
stubs/api/data/submissions.json
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"_id": "sub001",
|
||||||
|
"id": "sub001",
|
||||||
|
"user": {
|
||||||
|
"_id": "user001",
|
||||||
|
"id": "user001",
|
||||||
|
"nickname": "alex_student",
|
||||||
|
"createdAt": "2024-10-15T08:30:00.000Z"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"_id": "507f1f77bcf86cd799439011",
|
||||||
|
"id": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Реализовать сортировку массива",
|
||||||
|
"description": "# Задание: Сортировка массива\n\nНапишите функцию `sortArray(arr)`, которая сортирует массив чисел по возрастанию.",
|
||||||
|
"createdAt": "2024-11-01T10:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-01T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
"result": "function sortArray(arr) {\n return arr.sort((a, b) => a - b);\n}",
|
||||||
|
"status": "needs_revision",
|
||||||
|
"queueId": "q001",
|
||||||
|
"feedback": "Ваше решение изменяет исходный массив. Необходимо создать копию массива перед сортировкой. Используйте spread оператор или Array.from().",
|
||||||
|
"submittedAt": "2024-11-01T15:30:00.000Z",
|
||||||
|
"checkedAt": "2024-11-01T15:30:03.500Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "sub002",
|
||||||
|
"id": "sub002",
|
||||||
|
"user": {
|
||||||
|
"_id": "user001",
|
||||||
|
"id": "user001",
|
||||||
|
"nickname": "alex_student",
|
||||||
|
"createdAt": "2024-10-15T08:30:00.000Z"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"_id": "507f1f77bcf86cd799439011",
|
||||||
|
"id": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Реализовать сортировку массива",
|
||||||
|
"description": "# Задание: Сортировка массива",
|
||||||
|
"createdAt": "2024-11-01T10:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-01T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
"result": "function sortArray(arr) {\n return [...arr].sort((a, b) => a - b);\n}",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "q002",
|
||||||
|
"feedback": "Отлично! Ваше решение корректно создаёт копию массива и сортирует её. Сложность O(n log n) соответствует требованиям.",
|
||||||
|
"submittedAt": "2024-11-01T15:45:00.000Z",
|
||||||
|
"checkedAt": "2024-11-01T15:45:02.800Z",
|
||||||
|
"attemptNumber": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "sub003",
|
||||||
|
"id": "sub003",
|
||||||
|
"user": {
|
||||||
|
"_id": "user002",
|
||||||
|
"id": "user002",
|
||||||
|
"nickname": "maria_dev",
|
||||||
|
"createdAt": "2024-10-16T10:15:00.000Z"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"_id": "507f1f77bcf86cd799439013",
|
||||||
|
"id": "507f1f77bcf86cd799439013",
|
||||||
|
"title": "Компонент React формы",
|
||||||
|
"description": "# Задание: Форма регистрации",
|
||||||
|
"createdAt": "2024-11-03T09:15:00.000Z",
|
||||||
|
"updatedAt": "2024-11-03T09:15:00.000Z"
|
||||||
|
},
|
||||||
|
"result": "import React, { useState } from 'react';\n\nfunction RegistrationForm() {\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [confirmPassword, setConfirmPassword] = useState('');\n const [errors, setErrors] = useState({});\n\n const handleSubmit = (e) => {\n e.preventDefault();\n const newErrors = {};\n if (!email.includes('@')) newErrors.email = 'Invalid email';\n if (password.length < 6) newErrors.password = 'Too short';\n if (password !== confirmPassword) newErrors.confirmPassword = 'Passwords do not match';\n \n if (Object.keys(newErrors).length === 0) {\n console.log('Form submitted');\n } else {\n setErrors(newErrors);\n }\n };\n\n return (\n <form onSubmit={handleSubmit}>\n <input value={email} onChange={e => setEmail(e.target.value)} />\n {errors.email && <span>{errors.email}</span>}\n <input type=\"password\" value={password} onChange={e => setPassword(e.target.value)} />\n {errors.password && <span>{errors.password}</span>}\n <input type=\"password\" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />\n {errors.confirmPassword && <span>{errors.confirmPassword}</span>}\n <button type=\"submit\">Register</button>\n </form>\n );\n}",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "q003",
|
||||||
|
"feedback": "Превосходно! Использованы controlled components, есть валидация, обработка ошибок и правильное управление state. Всё соответствует требованиям.",
|
||||||
|
"submittedAt": "2024-11-03T16:20:00.000Z",
|
||||||
|
"checkedAt": "2024-11-03T16:20:04.200Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "sub004",
|
||||||
|
"id": "sub004",
|
||||||
|
"user": {
|
||||||
|
"_id": "user003",
|
||||||
|
"id": "user003",
|
||||||
|
"nickname": "ivan_coder",
|
||||||
|
"createdAt": "2024-10-17T14:20:00.000Z"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"_id": "507f1f77bcf86cd799439012",
|
||||||
|
"id": "507f1f77bcf86cd799439012",
|
||||||
|
"title": "Создать REST API endpoint",
|
||||||
|
"description": "# Задание: REST API для пользователей",
|
||||||
|
"createdAt": "2024-11-02T12:30:00.000Z",
|
||||||
|
"updatedAt": "2024-11-02T12:30:00.000Z"
|
||||||
|
},
|
||||||
|
"result": "app.get('/api/users', async (req, res) => {\n const users = await User.find();\n res.json({ users });\n});",
|
||||||
|
"status": "needs_revision",
|
||||||
|
"queueId": "q004",
|
||||||
|
"feedback": "В решении отсутствует пагинация, обработка ошибок и валидация параметров. Необходимо добавить параметры page и limit, обернуть код в try-catch и валидировать входные данные.",
|
||||||
|
"submittedAt": "2024-11-02T17:00:00.000Z",
|
||||||
|
"checkedAt": "2024-11-02T17:00:03.100Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "sub005",
|
||||||
|
"id": "sub005",
|
||||||
|
"user": {
|
||||||
|
"_id": "user004",
|
||||||
|
"id": "user004",
|
||||||
|
"nickname": "olga_js",
|
||||||
|
"createdAt": "2024-10-18T09:00:00.000Z"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"_id": "507f1f77bcf86cd799439015",
|
||||||
|
"id": "507f1f77bcf86cd799439015",
|
||||||
|
"title": "Валидация формы",
|
||||||
|
"description": "# Задание: Валидация email",
|
||||||
|
"createdAt": "2024-11-05T11:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-05T11:00:00.000Z"
|
||||||
|
},
|
||||||
|
"result": "function validateEmail(email) {\n if (!email || email.trim() === '') return false;\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return emailRegex.test(email);\n}",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "q005",
|
||||||
|
"feedback": "Отлично! Функция обрабатывает все edge cases: пустая строка, отсутствие @, отсутствие домена. Regex валидация корректная.",
|
||||||
|
"submittedAt": "2024-11-05T14:10:00.000Z",
|
||||||
|
"checkedAt": "2024-11-05T14:10:02.500Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "sub006",
|
||||||
|
"id": "sub006",
|
||||||
|
"user": {
|
||||||
|
"_id": "user005",
|
||||||
|
"id": "user005",
|
||||||
|
"nickname": "dmitry_react",
|
||||||
|
"createdAt": "2024-10-20T11:45:00.000Z"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"_id": "507f1f77bcf86cd799439011",
|
||||||
|
"id": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Реализовать сортировку массива",
|
||||||
|
"description": "# Задание: Сортировка массива",
|
||||||
|
"createdAt": "2024-11-01T10:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-01T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
"result": "function sortArray(arr) {\n const result = [];\n for (let i = 0; i < arr.length; i++) {\n for (let j = i + 1; j < arr.length; j++) {\n if (arr[i] > arr[j]) {\n [arr[i], arr[j]] = [arr[j], arr[i]];\n }\n }\n result.push(arr[i]);\n }\n return result;\n}",
|
||||||
|
"status": "needs_revision",
|
||||||
|
"queueId": "q006",
|
||||||
|
"feedback": "Ваше решение использует bubble sort с сложностью O(n²), что не соответствует требованиям. Необходимо использовать алгоритм с сложностью O(n log n), например, встроенный метод sort().",
|
||||||
|
"submittedAt": "2024-11-01T18:30:00.000Z",
|
||||||
|
"checkedAt": "2024-11-01T18:30:03.900Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "sub007",
|
||||||
|
"id": "sub007",
|
||||||
|
"user": {
|
||||||
|
"_id": "user006",
|
||||||
|
"id": "user006",
|
||||||
|
"nickname": "anna_frontend",
|
||||||
|
"createdAt": "2024-10-22T16:30:00.000Z"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"_id": "507f1f77bcf86cd799439014",
|
||||||
|
"id": "507f1f77bcf86cd799439014",
|
||||||
|
"title": "SQL запрос с JOIN",
|
||||||
|
"description": "# Задание: SQL запрос",
|
||||||
|
"createdAt": "2024-11-04T14:20:00.000Z",
|
||||||
|
"updatedAt": "2024-11-04T14:20:00.000Z"
|
||||||
|
},
|
||||||
|
"result": "SELECT users.name, users.email, orders.id as order_id, orders.created_at,\n products.name as product_name, products.price, order_items.quantity\nFROM users\nINNER JOIN orders ON users.id = orders.user_id\nINNER JOIN order_items ON orders.id = order_items.order_id\nINNER JOIN products ON order_items.product_id = products.id\nWHERE orders.status = 'active'\nORDER BY orders.created_at DESC;",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "q007",
|
||||||
|
"feedback": "Отличный запрос! Использованы правильные JOIN'ы, добавлена фильтрация по активным заказам и сортировка по дате. Всё соответствует требованиям.",
|
||||||
|
"submittedAt": "2024-11-04T20:15:00.000Z",
|
||||||
|
"checkedAt": "2024-11-04T20:15:02.700Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "sub008",
|
||||||
|
"id": "sub008",
|
||||||
|
"user": {
|
||||||
|
"_id": "user007",
|
||||||
|
"id": "user007",
|
||||||
|
"nickname": "sergey_backend",
|
||||||
|
"createdAt": "2024-10-25T13:00:00.000Z"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"_id": "507f1f77bcf86cd799439012",
|
||||||
|
"id": "507f1f77bcf86cd799439012",
|
||||||
|
"title": "Создать REST API endpoint",
|
||||||
|
"description": "# Задание: REST API для пользователей",
|
||||||
|
"createdAt": "2024-11-02T12:30:00.000Z",
|
||||||
|
"updatedAt": "2024-11-02T12:30:00.000Z"
|
||||||
|
},
|
||||||
|
"result": "app.get('/api/users', async (req, res) => {\n try {\n const page = parseInt(req.query.page) || 1;\n const limit = parseInt(req.query.limit) || 10;\n \n if (page < 1 || limit < 1 || limit > 100) {\n return res.status(400).json({ error: 'Invalid pagination parameters' });\n }\n \n const skip = (page - 1) * limit;\n const users = await User.find().skip(skip).limit(limit);\n const total = await User.countDocuments();\n \n res.json({\n users,\n total,\n page,\n limit\n });\n } catch (error) {\n res.status(500).json({ error: 'Internal server error' });\n }\n});",
|
||||||
|
"status": "accepted",
|
||||||
|
"queueId": "q008",
|
||||||
|
"feedback": "Превосходная работа! Есть пагинация, валидация параметров, обработка ошибок. Код чистый и следует best practices.",
|
||||||
|
"submittedAt": "2024-11-02T21:30:00.000Z",
|
||||||
|
"checkedAt": "2024-11-02T21:30:04.100Z",
|
||||||
|
"attemptNumber": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
72
stubs/api/data/tasks.json
Normal file
72
stubs/api/data/tasks.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439011",
|
||||||
|
"id": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Реализовать сортировку массива",
|
||||||
|
"description": "# Задание: Сортировка массива\n\nНапишите функцию `sortArray(arr)`, которая сортирует массив чисел по возрастанию.\n\n## Требования:\n\n- Функция должна принимать массив чисел\n- Возвращать отсортированный массив\n- Не изменять исходный массив\n\n## Пример:\n\n```javascript\nconst arr = [5, 2, 8, 1, 9];\nconst sorted = sortArray(arr);\nconsole.log(sorted); // [1, 2, 5, 8, 9]\n```",
|
||||||
|
"hiddenInstructions": "Проверь, чтобы сложность алгоритма была не хуже O(n log n). Не принимай bubble sort или простые O(n²) решения. Убедись, что исходный массив не изменяется.",
|
||||||
|
"creator": {
|
||||||
|
"sub": "teacher-123",
|
||||||
|
"preferred_username": "ivanov_teacher",
|
||||||
|
"email": "ivanov@example.com"
|
||||||
|
},
|
||||||
|
"createdAt": "2024-11-01T10:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-01T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439012",
|
||||||
|
"id": "507f1f77bcf86cd799439012",
|
||||||
|
"title": "Создать REST API endpoint",
|
||||||
|
"description": "# Задание: REST API для пользователей\n\nСоздайте REST API endpoint для получения списка пользователей.\n\n## Требования:\n\n- Метод: GET\n- Путь: /api/users\n- Должна быть пагинация\n- Обработка ошибок\n- Валидация параметров\n\n## Пример ответа:\n\n```json\n{\n \"users\": [...],\n \"total\": 100,\n \"page\": 1,\n \"limit\": 10\n}\n```",
|
||||||
|
"hiddenInstructions": "Обязательна пагинация, обработка ошибок и валидация параметров. Если чего-то не хватает - укажи в feedback.",
|
||||||
|
"creator": {
|
||||||
|
"sub": "teacher-123",
|
||||||
|
"preferred_username": "ivanov_teacher",
|
||||||
|
"email": "ivanov@example.com"
|
||||||
|
},
|
||||||
|
"createdAt": "2024-11-02T12:30:00.000Z",
|
||||||
|
"updatedAt": "2024-11-02T12:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439013",
|
||||||
|
"id": "507f1f77bcf86cd799439013",
|
||||||
|
"title": "Компонент React формы",
|
||||||
|
"description": "# Задание: Форма регистрации\n\nСоздайте компонент React для формы регистрации.\n\n## Требования:\n\n- Поля: email, password, confirmPassword\n- Валидация на стороне клиента\n- Использование controlled components\n- Обработка submit\n\n## Бонус:\n\n- TypeScript типы\n- Показ ошибок валидации",
|
||||||
|
"hiddenInstructions": "Обязательна валидация на стороне клиента, использование controlled components, и правильное управление state. Если используются uncontrolled components - отправь на доработку.",
|
||||||
|
"creator": {
|
||||||
|
"sub": "teacher-456",
|
||||||
|
"preferred_username": "petrova_teacher",
|
||||||
|
"email": "petrova@example.com"
|
||||||
|
},
|
||||||
|
"createdAt": "2024-11-03T09:15:00.000Z",
|
||||||
|
"updatedAt": "2024-11-03T09:15:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439014",
|
||||||
|
"id": "507f1f77bcf86cd799439014",
|
||||||
|
"title": "SQL запрос с JOIN",
|
||||||
|
"description": "# Задание: SQL запрос\n\nНапишите SQL запрос для выборки всех заказов пользователя вместе с информацией о товарах.\n\n## Структура таблиц:\n\n- users (id, name, email)\n- orders (id, user_id, created_at)\n- order_items (id, order_id, product_id, quantity)\n- products (id, name, price)\n\n## Требования:\n\n- Использовать JOIN\n- Отсортировать по дате создания заказа\n- Показать только активные заказы",
|
||||||
|
"creator": {
|
||||||
|
"sub": "teacher-123",
|
||||||
|
"preferred_username": "ivanov_teacher",
|
||||||
|
"email": "ivanov@example.com"
|
||||||
|
},
|
||||||
|
"createdAt": "2024-11-04T14:20:00.000Z",
|
||||||
|
"updatedAt": "2024-11-04T14:20:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439015",
|
||||||
|
"id": "507f1f77bcf86cd799439015",
|
||||||
|
"title": "Валидация формы",
|
||||||
|
"description": "# Задание: Валидация email\n\nНапишите функцию для валидации email адреса.\n\n## Требования:\n\n- Проверка формата email\n- Возвращает true/false\n- Обработка edge cases\n\n## Примеры:\n\n```javascript\nvalidateEmail('test@example.com') // true\nvalidateEmail('invalid-email') // false\nvalidateEmail('') // false\n```",
|
||||||
|
"hiddenInstructions": "Проверь, что функция обрабатывает edge cases: пустая строка, нет @, нет домена, множественные @. Если не все случаи покрыты - отправь на доработку.",
|
||||||
|
"creator": {
|
||||||
|
"sub": "teacher-456",
|
||||||
|
"preferred_username": "petrova_teacher",
|
||||||
|
"email": "petrova@example.com"
|
||||||
|
},
|
||||||
|
"createdAt": "2024-11-05T11:00:00.000Z",
|
||||||
|
"updatedAt": "2024-11-05T11:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
51
stubs/api/data/users.json
Normal file
51
stubs/api/data/users.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"_id": "user001",
|
||||||
|
"id": "user001",
|
||||||
|
"nickname": "alex_student",
|
||||||
|
"createdAt": "2024-10-15T08:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "user002",
|
||||||
|
"id": "user002",
|
||||||
|
"nickname": "maria_dev",
|
||||||
|
"createdAt": "2024-10-16T10:15:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "user003",
|
||||||
|
"id": "user003",
|
||||||
|
"nickname": "ivan_coder",
|
||||||
|
"createdAt": "2024-10-17T14:20:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "user004",
|
||||||
|
"id": "user004",
|
||||||
|
"nickname": "olga_js",
|
||||||
|
"createdAt": "2024-10-18T09:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "user005",
|
||||||
|
"id": "user005",
|
||||||
|
"nickname": "dmitry_react",
|
||||||
|
"createdAt": "2024-10-20T11:45:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "user006",
|
||||||
|
"id": "user006",
|
||||||
|
"nickname": "anna_frontend",
|
||||||
|
"createdAt": "2024-10-22T16:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "user007",
|
||||||
|
"id": "user007",
|
||||||
|
"nickname": "sergey_backend",
|
||||||
|
"createdAt": "2024-10-25T13:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "user008",
|
||||||
|
"id": "user008",
|
||||||
|
"nickname": "elena_fullstack",
|
||||||
|
"createdAt": "2024-10-28T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
391
stubs/api/index.js
Normal file
391
stubs/api/index.js
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const timer = (time = 300) => (req, res, next) => setTimeout(next, time);
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const loadJSON = (filename) => {
|
||||||
|
const filePath = path.join(__dirname, 'data', filename);
|
||||||
|
const data = fs.readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const respond = (res, data) => {
|
||||||
|
res.json({ error: null, data });
|
||||||
|
};
|
||||||
|
|
||||||
|
const respondError = (res, message, statusCode = 400) => {
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: { message },
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory storage (resets on server restart)
|
||||||
|
let tasksCache = null;
|
||||||
|
let chainsCache = null;
|
||||||
|
let usersCache = null;
|
||||||
|
let submissionsCache = null;
|
||||||
|
let statsCache = null;
|
||||||
|
|
||||||
|
const getTasks = () => {
|
||||||
|
if (!tasksCache) tasksCache = loadJSON('tasks.json');
|
||||||
|
return tasksCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChains = () => {
|
||||||
|
if (!chainsCache) chainsCache = loadJSON('chains.json');
|
||||||
|
return chainsCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsers = () => {
|
||||||
|
if (!usersCache) usersCache = loadJSON('users.json');
|
||||||
|
return usersCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubmissions = () => {
|
||||||
|
if (!submissionsCache) submissionsCache = loadJSON('submissions.json');
|
||||||
|
return submissionsCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStats = () => {
|
||||||
|
if (!statsCache) statsCache = loadJSON('stats.json');
|
||||||
|
return statsCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(timer());
|
||||||
|
|
||||||
|
// ============= TASKS =============
|
||||||
|
|
||||||
|
// GET /api/challenge/tasks
|
||||||
|
router.get('/challenge/tasks', (req, res) => {
|
||||||
|
const tasks = getTasks();
|
||||||
|
respond(res, tasks);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/challenge/task/:id
|
||||||
|
router.get('/challenge/task/:id', (req, res) => {
|
||||||
|
const tasks = getTasks();
|
||||||
|
const task = tasks.find(t => t.id === req.params.id);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return respondError(res, 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(res, task);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/challenge/task
|
||||||
|
router.post('/challenge/task', (req, res) => {
|
||||||
|
const { title, description, hiddenInstructions } = req.body;
|
||||||
|
|
||||||
|
if (!title || !description) {
|
||||||
|
return respondError(res, 'Title and description are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = getTasks();
|
||||||
|
const newTask = {
|
||||||
|
_id: `task_${Date.now()}`,
|
||||||
|
id: `task_${Date.now()}`,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
hiddenInstructions: hiddenInstructions || undefined,
|
||||||
|
creator: {
|
||||||
|
sub: 'teacher-123',
|
||||||
|
preferred_username: 'current_teacher',
|
||||||
|
email: 'teacher@example.com'
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
tasks.push(newTask);
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const stats = getStats();
|
||||||
|
stats.tasks = tasks.length;
|
||||||
|
|
||||||
|
respond(res, newTask);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/challenge/task/:id
|
||||||
|
router.put('/challenge/task/:id', (req, res) => {
|
||||||
|
const tasks = getTasks();
|
||||||
|
const taskIndex = tasks.findIndex(t => t.id === req.params.id);
|
||||||
|
|
||||||
|
if (taskIndex === -1) {
|
||||||
|
return respondError(res, 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, hiddenInstructions } = req.body;
|
||||||
|
const task = tasks[taskIndex];
|
||||||
|
|
||||||
|
if (title) task.title = title;
|
||||||
|
if (description) task.description = description;
|
||||||
|
if (hiddenInstructions !== undefined) {
|
||||||
|
task.hiddenInstructions = hiddenInstructions || undefined;
|
||||||
|
}
|
||||||
|
task.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
respond(res, task);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/challenge/task/:id
|
||||||
|
router.delete('/challenge/task/:id', (req, res) => {
|
||||||
|
const tasks = getTasks();
|
||||||
|
const taskIndex = tasks.findIndex(t => t.id === req.params.id);
|
||||||
|
|
||||||
|
if (taskIndex === -1) {
|
||||||
|
return respondError(res, 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.splice(taskIndex, 1);
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const stats = getStats();
|
||||||
|
stats.tasks = tasks.length;
|
||||||
|
|
||||||
|
respond(res, { success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= CHAINS =============
|
||||||
|
|
||||||
|
// GET /api/challenge/chains
|
||||||
|
router.get('/challenge/chains', (req, res) => {
|
||||||
|
const chains = getChains();
|
||||||
|
respond(res, chains);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/challenge/chain/:id
|
||||||
|
router.get('/challenge/chain/:id', (req, res) => {
|
||||||
|
const chains = getChains();
|
||||||
|
const chain = chains.find(c => c.id === req.params.id);
|
||||||
|
|
||||||
|
if (!chain) {
|
||||||
|
return respondError(res, 'Chain not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(res, chain);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/challenge/chain
|
||||||
|
router.post('/challenge/chain', (req, res) => {
|
||||||
|
const { name, tasks } = req.body;
|
||||||
|
|
||||||
|
if (!name || !tasks || !Array.isArray(tasks)) {
|
||||||
|
return respondError(res, 'Name and tasks array are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chains = getChains();
|
||||||
|
const allTasks = getTasks();
|
||||||
|
|
||||||
|
// Populate tasks
|
||||||
|
const populatedTasks = tasks.map(taskId => {
|
||||||
|
const task = allTasks.find(t => t.id === taskId);
|
||||||
|
return task ? {
|
||||||
|
_id: task._id,
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
description: task.description,
|
||||||
|
createdAt: task.createdAt,
|
||||||
|
updatedAt: task.updatedAt
|
||||||
|
} : null;
|
||||||
|
}).filter(t => t !== null);
|
||||||
|
|
||||||
|
const newChain = {
|
||||||
|
_id: `chain_${Date.now()}`,
|
||||||
|
id: `chain_${Date.now()}`,
|
||||||
|
name,
|
||||||
|
tasks: populatedTasks,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
chains.push(newChain);
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const stats = getStats();
|
||||||
|
stats.chains = chains.length;
|
||||||
|
|
||||||
|
respond(res, newChain);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/challenge/chain/:id
|
||||||
|
router.put('/challenge/chain/:id', (req, res) => {
|
||||||
|
const chains = getChains();
|
||||||
|
const chainIndex = chains.findIndex(c => c.id === req.params.id);
|
||||||
|
|
||||||
|
if (chainIndex === -1) {
|
||||||
|
return respondError(res, 'Chain not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, tasks } = req.body;
|
||||||
|
const chain = chains[chainIndex];
|
||||||
|
|
||||||
|
if (name) chain.name = name;
|
||||||
|
|
||||||
|
if (tasks && Array.isArray(tasks)) {
|
||||||
|
const allTasks = getTasks();
|
||||||
|
const populatedTasks = tasks.map(taskId => {
|
||||||
|
const task = allTasks.find(t => t.id === taskId);
|
||||||
|
return task ? {
|
||||||
|
_id: task._id,
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
description: task.description,
|
||||||
|
createdAt: task.createdAt,
|
||||||
|
updatedAt: task.updatedAt
|
||||||
|
} : null;
|
||||||
|
}).filter(t => t !== null);
|
||||||
|
|
||||||
|
chain.tasks = populatedTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
respond(res, chain);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/challenge/chain/:id
|
||||||
|
router.delete('/challenge/chain/:id', (req, res) => {
|
||||||
|
const chains = getChains();
|
||||||
|
const chainIndex = chains.findIndex(c => c.id === req.params.id);
|
||||||
|
|
||||||
|
if (chainIndex === -1) {
|
||||||
|
return respondError(res, 'Chain not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
chains.splice(chainIndex, 1);
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const stats = getStats();
|
||||||
|
stats.chains = chains.length;
|
||||||
|
|
||||||
|
respond(res, { success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= USERS =============
|
||||||
|
|
||||||
|
// GET /api/challenge/users
|
||||||
|
router.get('/challenge/users', (req, res) => {
|
||||||
|
const users = getUsers();
|
||||||
|
respond(res, users);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= STATS =============
|
||||||
|
|
||||||
|
// GET /api/challenge/stats
|
||||||
|
router.get('/challenge/stats', (req, res) => {
|
||||||
|
const stats = getStats();
|
||||||
|
respond(res, stats);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/challenge/user/:userId/stats
|
||||||
|
router.get('/challenge/user/:userId/stats', (req, res) => {
|
||||||
|
const users = getUsers();
|
||||||
|
const submissions = getSubmissions();
|
||||||
|
const chains = getChains();
|
||||||
|
|
||||||
|
const user = users.find(u => u.id === req.params.userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return respondError(res, 'User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSubmissions = submissions.filter(s => s.user.id === req.params.userId);
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const completedTasks = new Set();
|
||||||
|
const taskStats = {};
|
||||||
|
|
||||||
|
userSubmissions.forEach(sub => {
|
||||||
|
const taskId = sub.task.id;
|
||||||
|
|
||||||
|
if (!taskStats[taskId]) {
|
||||||
|
taskStats[taskId] = {
|
||||||
|
taskId: taskId,
|
||||||
|
taskTitle: sub.task.title,
|
||||||
|
attempts: [],
|
||||||
|
totalAttempts: 0,
|
||||||
|
status: 'not_attempted',
|
||||||
|
lastAttemptAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
taskStats[taskId].attempts.push({
|
||||||
|
attemptNumber: sub.attemptNumber,
|
||||||
|
status: sub.status,
|
||||||
|
submittedAt: sub.submittedAt,
|
||||||
|
checkedAt: sub.checkedAt,
|
||||||
|
feedback: sub.feedback
|
||||||
|
});
|
||||||
|
|
||||||
|
taskStats[taskId].totalAttempts++;
|
||||||
|
taskStats[taskId].status = sub.status;
|
||||||
|
taskStats[taskId].lastAttemptAt = sub.submittedAt;
|
||||||
|
|
||||||
|
if (sub.status === 'accepted') {
|
||||||
|
completedTasks.add(taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskStatsArray = Object.values(taskStats);
|
||||||
|
|
||||||
|
// Chain stats
|
||||||
|
const chainStats = chains.map(chain => {
|
||||||
|
const completedInChain = chain.tasks.filter(t => completedTasks.has(t.id)).length;
|
||||||
|
return {
|
||||||
|
chainId: chain.id,
|
||||||
|
chainName: chain.name,
|
||||||
|
totalTasks: chain.tasks.length,
|
||||||
|
completedTasks: completedInChain,
|
||||||
|
progress: chain.tasks.length > 0 ? (completedInChain / chain.tasks.length * 100) : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCheckTime = userSubmissions
|
||||||
|
.filter(s => s.checkedAt)
|
||||||
|
.reduce((sum, s) => {
|
||||||
|
const submitted = new Date(s.submittedAt).getTime();
|
||||||
|
const checked = new Date(s.checkedAt).getTime();
|
||||||
|
return sum + (checked - submitted);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const userStats = {
|
||||||
|
totalTasksAttempted: taskStatsArray.length,
|
||||||
|
completedTasks: completedTasks.size,
|
||||||
|
inProgressTasks: taskStatsArray.filter(t => t.status === 'in_progress').length,
|
||||||
|
needsRevisionTasks: taskStatsArray.filter(t => t.status === 'needs_revision').length,
|
||||||
|
totalSubmissions: userSubmissions.length,
|
||||||
|
averageCheckTimeMs: userSubmissions.length > 0 ? totalCheckTime / userSubmissions.length : 0,
|
||||||
|
taskStats: taskStatsArray,
|
||||||
|
chainStats: chainStats
|
||||||
|
};
|
||||||
|
|
||||||
|
respond(res, userStats);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============= SUBMISSIONS =============
|
||||||
|
|
||||||
|
// GET /api/challenge/submissions
|
||||||
|
router.get('/challenge/submissions', (req, res) => {
|
||||||
|
const submissions = getSubmissions();
|
||||||
|
respond(res, submissions);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/challenge/user/:userId/submissions
|
||||||
|
router.get('/challenge/user/:userId/submissions', (req, res) => {
|
||||||
|
const submissions = getSubmissions();
|
||||||
|
const taskId = req.query.taskId;
|
||||||
|
|
||||||
|
let filtered = submissions.filter(s => s.user.id === req.params.userId);
|
||||||
|
|
||||||
|
if (taskId) {
|
||||||
|
filtered = filtered.filter(s => s.task.id === taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(res, filtered);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "es2017"],
|
||||||
|
"outDir": "./dist/",
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "es6",
|
||||||
|
"jsx": "react",
|
||||||
|
"typeRoots": ["node_modules/@types", "./@types"],
|
||||||
|
"types": ["webpack-env", "node"],
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
},
|
||||||
|
"types": [
|
||||||
|
"@types/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.test.tsx",
|
||||||
|
"node_modules/@types/jest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
7
types.d.ts
vendored
Normal file
7
types.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
declare module '*.svg' {
|
||||||
|
const src: string
|
||||||
|
export default src
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const __webpack_public_path__: string
|
||||||
|
|
||||||
Reference in New Issue
Block a user