init + api use
This commit is contained in:
commit
e777b57991
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user