init + api use

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-11-03 17:59:08 +03:00
commit e777b57991
52 changed files with 20725 additions and 0 deletions

132
.gitignore vendored Normal file
View 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
View 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
View 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
View 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'
}
}

View 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 интерфейса!

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

178
docs/README.md Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
{
"challenge.title": "Challenge"
}

3
locales/ru.json Normal file
View File

@ -0,0 +1,3 @@
{
"challenge.title": "Challenge"
}

12927
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View 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
View 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
View 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,
});

View 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
View 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
View 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
View 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

View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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()
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View File

@ -0,0 +1,2 @@
export { MainPage as default } from './main'

11
src/pages/main/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
export const MainPage = () => {
return (
<div>
<h1>Главная страница проекта challenge-admin-pl</h1>
<p>Это базовая страница с React Router</p>
</div>
)
}

View 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>
)
}

View 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="# Заголовок задания&#10;&#10;Описание задания в формате 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>
)
}

View 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>
)
}

View 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
View 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
View 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[]
}

View 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
View 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.

View 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
View 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
}
}

View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
declare module '*.svg' {
const src: string
export default src
}
declare const __webpack_public_path__: string