fix api calls
This commit is contained in:
@@ -1,635 +0,0 @@
|
|||||||
# 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 интерфейса!
|
|
||||||
|
|
||||||
544
docs/CHALLENGE_API_README.md
Normal file
544
docs/CHALLENGE_API_README.md
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
# Challenge Service API Documentation
|
||||||
|
|
||||||
|
Сервис для проверки заданий в реальном времени через LLM (GigaChat).
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Система позволяет:
|
||||||
|
- Создавать задания с описанием в Markdown
|
||||||
|
- Объединять задания в цепочки
|
||||||
|
- Пользователям проходить задания и отправлять результаты
|
||||||
|
- Автоматически проверять результаты через GigaChat с ограничением потоков
|
||||||
|
- Отслеживать статистику по пользователям и системе
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
В `.env` файле добавьте:
|
||||||
|
|
||||||
|
```env
|
||||||
|
CHALLENGE_LLM_THREADS=2 # Количество одновременных проверок через LLM
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
Все endpoints начинаются с префикса `/api/challenge`
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
|
||||||
|
#### POST `/auth`
|
||||||
|
|
||||||
|
Регистрация/авторизация пользователя по nickname.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nickname": "user123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
- `nickname`: обязательное поле, от 3 до 50 символов
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": {
|
||||||
|
"ok": true,
|
||||||
|
"userId": "507f1f77bcf86cd799439011"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Управление заданиями
|
||||||
|
|
||||||
|
**Важно:** Все операции создания/обновления/удаления заданий требуют авторизации через Keycloak с ролью `teacher` или `challenge-author`.
|
||||||
|
|
||||||
|
#### POST `/task`
|
||||||
|
|
||||||
|
Создание нового задания (требует роль `teacher` или `challenge-author`).
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <keycloak_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Написать функцию сортировки",
|
||||||
|
"description": "# Задание\n\nНапишите функцию сортировки массива чисел...",
|
||||||
|
"hiddenInstructions": "Проверь сложность алгоритма. Должна быть O(n log n), не принимай bubble sort"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
- `title`: обязательное поле, максимум 255 символов
|
||||||
|
- `description`: обязательное поле
|
||||||
|
- `hiddenInstructions`: опциональное поле
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": {
|
||||||
|
"_id": "507f1f77bcf86cd799439011",
|
||||||
|
"title": "Написать функцию сортировки",
|
||||||
|
"description": "# Задание\n\n...",
|
||||||
|
"hiddenInstructions": "Проверь сложность алгоритма...",
|
||||||
|
"creator": { "sub": "...", "preferred_username": "teacher1" },
|
||||||
|
"createdAt": "2023-10-29T12:00:00.000Z",
|
||||||
|
"updatedAt": "2023-10-29T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** Поле `hiddenInstructions` опционально. Эти инструкции будут переданы в LLM при проверке, но скрыты от студентов.
|
||||||
|
|
||||||
|
#### GET `/task/:taskId`
|
||||||
|
|
||||||
|
Получение задания по ID.
|
||||||
|
|
||||||
|
**Важно:** Поля `hiddenInstructions` и `creator` возвращаются только для пользователей с ролью `teacher` или `challenge-author`. Обычные студенты их не увидят.
|
||||||
|
|
||||||
|
#### GET `/tasks`
|
||||||
|
|
||||||
|
Получение всех заданий.
|
||||||
|
|
||||||
|
**Примечание:** Для не-преподавателей поля `hiddenInstructions` и `creator` скрыты.
|
||||||
|
|
||||||
|
#### PUT `/task/:taskId`
|
||||||
|
|
||||||
|
Обновление задания (требует роль `teacher` или `challenge-author`).
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <keycloak_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Новый заголовок",
|
||||||
|
"description": "Новое описание",
|
||||||
|
"hiddenInstructions": "Обновленные инструкции для LLM"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
- `title`: опциональное поле, максимум 255 символов
|
||||||
|
- `description`: опциональное поле
|
||||||
|
- `hiddenInstructions`: опциональное поле
|
||||||
|
|
||||||
|
#### DELETE `/task/:taskId`
|
||||||
|
|
||||||
|
Удаление задания (требует роль `teacher` или `challenge-author`). Также удаляется из всех цепочек.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <keycloak_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Управление цепочками заданий
|
||||||
|
|
||||||
|
**Важно:** Все операции создания/обновления/удаления цепочек требуют авторизации через Keycloak с ролью `teacher` или `challenge-author`.
|
||||||
|
|
||||||
|
#### POST `/chain`
|
||||||
|
|
||||||
|
Создание цепочки заданий (требует роль `teacher` или `challenge-author`).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Основы программирования",
|
||||||
|
"taskIds": ["507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
- `name`: обязательное поле, максимум 255 символов
|
||||||
|
- `taskIds`: обязательное поле, массив строк
|
||||||
|
|
||||||
|
#### GET `/chains`
|
||||||
|
|
||||||
|
Получение всех цепочек с заданиями.
|
||||||
|
|
||||||
|
**Примечание:** Для не-преподавателей поля `hiddenInstructions` и `creator` скрыты в заданиях внутри цепочек.
|
||||||
|
|
||||||
|
#### GET `/chain/:chainId`
|
||||||
|
|
||||||
|
Получение цепочки по ID.
|
||||||
|
|
||||||
|
**Примечание:** Для не-преподавателей поля `hiddenInstructions` и `creator` скрыты в заданиях внутри цепочки.
|
||||||
|
|
||||||
|
#### PUT `/chain/:chainId`
|
||||||
|
|
||||||
|
Обновление цепочки (требует роль `teacher` или `challenge-author`).
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <keycloak_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Новое название цепочки",
|
||||||
|
"taskIds": ["507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
- `name`: опциональное поле, максимум 255 символов
|
||||||
|
- `taskIds`: опциональное поле, массив строк
|
||||||
|
|
||||||
|
#### DELETE `/chain/:chainId`
|
||||||
|
|
||||||
|
Удаление цепочки (требует роль `teacher` или `challenge-author`).
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <keycloak_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отправка и проверка заданий
|
||||||
|
|
||||||
|
#### POST `/submit`
|
||||||
|
|
||||||
|
Отправка результата выполнения задания на проверку.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "507f1f77bcf86cd799439011",
|
||||||
|
"taskId": "507f1f77bcf86cd799439012",
|
||||||
|
"result": "function sort(arr) { return arr.sort((a, b) => a - b); }"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": {
|
||||||
|
"queueId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"submissionId": "507f1f77bcf86cd799439013"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Валидация:**
|
||||||
|
- `userId`: обязательное поле
|
||||||
|
- `taskId`: обязательное поле
|
||||||
|
- `result`: обязательное поле
|
||||||
|
|
||||||
|
#### GET `/check-status/:queueId`
|
||||||
|
|
||||||
|
Polling endpoint для проверки статуса проверки.
|
||||||
|
|
||||||
|
**Response (в процессе):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": {
|
||||||
|
"status": "waiting",
|
||||||
|
"position": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (завершено):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": {
|
||||||
|
"status": "completed",
|
||||||
|
"submission": {
|
||||||
|
"_id": "507f1f77bcf86cd799439013",
|
||||||
|
"user": {...},
|
||||||
|
"task": {...},
|
||||||
|
"result": "...",
|
||||||
|
"status": "accepted",
|
||||||
|
"feedback": "Отлично! Задание выполнено правильно.",
|
||||||
|
"attemptNumber": 1,
|
||||||
|
"submittedAt": "2023-10-29T12:00:00.000Z",
|
||||||
|
"checkedAt": "2023-10-29T12:00:05.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Статусы проверки:
|
||||||
|
- `waiting` - ожидает в очереди
|
||||||
|
- `in_progress` - проверяется
|
||||||
|
- `completed` - проверка завершена
|
||||||
|
- `error` - ошибка при проверке
|
||||||
|
- `not_found` - не найдено
|
||||||
|
|
||||||
|
Статусы submission:
|
||||||
|
- `pending` - ожидает проверки
|
||||||
|
- `in_progress` - проверяется
|
||||||
|
- `accepted` - принято
|
||||||
|
- `needs_revision` - требует доработки
|
||||||
|
|
||||||
|
### Просмотр попыток
|
||||||
|
|
||||||
|
#### GET `/user/:userId/submissions?taskId=...`
|
||||||
|
|
||||||
|
Получение всех попыток пользователя (опционально для конкретного задания).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"_id": "507f1f77bcf86cd799439013",
|
||||||
|
"task": {...},
|
||||||
|
"result": "...",
|
||||||
|
"status": "accepted",
|
||||||
|
"feedback": "...",
|
||||||
|
"attemptNumber": 2,
|
||||||
|
"submittedAt": "2023-10-29T12:00:00.000Z",
|
||||||
|
"checkedAt": "2023-10-29T12:00:05.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Статистика
|
||||||
|
|
||||||
|
#### GET `/user/:userId/stats`
|
||||||
|
|
||||||
|
Детальная статистика пользователя.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": {
|
||||||
|
"totalTasksAttempted": 5,
|
||||||
|
"completedTasks": 3,
|
||||||
|
"inProgressTasks": 1,
|
||||||
|
"needsRevisionTasks": 1,
|
||||||
|
"totalSubmissions": 8,
|
||||||
|
"averageCheckTimeMs": 5234,
|
||||||
|
"taskStats": [
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439012",
|
||||||
|
"taskTitle": "Написать функцию сортировки",
|
||||||
|
"attempts": [...],
|
||||||
|
"totalAttempts": 2,
|
||||||
|
"status": "completed",
|
||||||
|
"lastAttemptAt": "2023-10-29T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chainStats": [
|
||||||
|
{
|
||||||
|
"chainId": "507f1f77bcf86cd799439014",
|
||||||
|
"chainName": "Основы программирования",
|
||||||
|
"totalTasks": 5,
|
||||||
|
"completedTasks": 3,
|
||||||
|
"progress": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** Статусы заданий в `taskStats.status`:
|
||||||
|
- `not_attempted` - задание не начато
|
||||||
|
- `pending` - ожидает проверки
|
||||||
|
- `in_progress` - проверяется
|
||||||
|
- `needs_revision` - требует доработки
|
||||||
|
- `completed` - выполнено
|
||||||
|
|
||||||
|
#### GET `/stats`
|
||||||
|
|
||||||
|
Общая статистика системы.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": {
|
||||||
|
"users": 150,
|
||||||
|
"tasks": 25,
|
||||||
|
"chains": 5,
|
||||||
|
"submissions": {
|
||||||
|
"total": 850,
|
||||||
|
"accepted": 420,
|
||||||
|
"rejected": 380,
|
||||||
|
"pending": 30,
|
||||||
|
"inProgress": 20
|
||||||
|
},
|
||||||
|
"averageCheckTimeMs": 5500,
|
||||||
|
"queue": {
|
||||||
|
"queueLength": 12,
|
||||||
|
"waiting": 10,
|
||||||
|
"inProgress": 2,
|
||||||
|
"maxConcurrency": 2,
|
||||||
|
"currentlyProcessing": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET `/stats/v2`
|
||||||
|
|
||||||
|
Расширенная статистика системы с детальными данными для таблиц и прогресс-баров.
|
||||||
|
|
||||||
|
**Query параметры:**
|
||||||
|
- `chainId` (опционально): фильтрация статистики по конкретной цепочке
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": null,
|
||||||
|
"data": {
|
||||||
|
"users": 150,
|
||||||
|
"tasks": 25,
|
||||||
|
"chains": 5,
|
||||||
|
"submissions": {
|
||||||
|
"total": 850,
|
||||||
|
"accepted": 420,
|
||||||
|
"rejected": 380,
|
||||||
|
"pending": 30,
|
||||||
|
"inProgress": 20
|
||||||
|
},
|
||||||
|
"averageCheckTimeMs": 5500,
|
||||||
|
"queue": {
|
||||||
|
"queueLength": 12,
|
||||||
|
"waiting": 10,
|
||||||
|
"inProgress": 2,
|
||||||
|
"maxConcurrency": 2,
|
||||||
|
"currentlyProcessing": 2
|
||||||
|
},
|
||||||
|
"tasksTable": [
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439012",
|
||||||
|
"title": "Написать функцию сортировки",
|
||||||
|
"totalAttempts": 45,
|
||||||
|
"uniqueUsers": 20,
|
||||||
|
"acceptedCount": 18,
|
||||||
|
"successRate": 90,
|
||||||
|
"averageAttemptsToSuccess": 1.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activeParticipants": [
|
||||||
|
{
|
||||||
|
"userId": "507f1f77bcf86cd799439011",
|
||||||
|
"nickname": "user123",
|
||||||
|
"totalSubmissions": 10,
|
||||||
|
"completedTasks": 5,
|
||||||
|
"chainProgress": [
|
||||||
|
{
|
||||||
|
"chainId": "507f1f77bcf86cd799439014",
|
||||||
|
"chainName": "Основы программирования",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"completedTasks": 5,
|
||||||
|
"progressPercent": 50
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chainsDetailed": [
|
||||||
|
{
|
||||||
|
"chainId": "507f1f77bcf86cd799439014",
|
||||||
|
"name": "Основы программирования",
|
||||||
|
"totalTasks": 10,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439012",
|
||||||
|
"title": "Написать функцию сортировки",
|
||||||
|
"description": "# Задание\n\n..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"participantProgress": [
|
||||||
|
{
|
||||||
|
"userId": "507f1f77bcf86cd799439011",
|
||||||
|
"nickname": "user123",
|
||||||
|
"taskProgress": [
|
||||||
|
{
|
||||||
|
"taskId": "507f1f77bcf86cd799439012",
|
||||||
|
"taskTitle": "Написать функцию сортировки",
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completedCount": 5,
|
||||||
|
"progressPercent": 50
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** Статусы задач в `taskProgress`:
|
||||||
|
- `not_started` - задание не начато
|
||||||
|
- `pending` - ожидает проверки
|
||||||
|
- `in_progress` - проверяется
|
||||||
|
- `needs_revision` - требует доработки
|
||||||
|
- `completed` - выполнено
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
### Модели данных
|
||||||
|
|
||||||
|
- **ChallengeUser** - пользователи сервиса
|
||||||
|
- **ChallengeTask** - отдельные задания
|
||||||
|
- **ChallengeChain** - цепочки заданий
|
||||||
|
- **ChallengeSubmission** - результаты выполнения заданий
|
||||||
|
|
||||||
|
### Сервисы
|
||||||
|
|
||||||
|
- **challenge-checker.ts** - проверка заданий через GigaChat
|
||||||
|
- **ChallengeCheckQueue.ts** - управление очередью проверок
|
||||||
|
- **challengeQueueInstance.ts** - singleton экземпляр очереди
|
||||||
|
|
||||||
|
### Очередь проверки
|
||||||
|
|
||||||
|
Очередь работает in-memory с ограничением на количество одновременных проверок.
|
||||||
|
|
||||||
|
- Элементы добавляются в очередь при отправке задания
|
||||||
|
- Обработка происходит автоматически каждую секунду
|
||||||
|
- Количество параллельных проверок ограничено `CHALLENGE_LLM_THREADS`
|
||||||
|
- После завершения проверки результаты сохраняются в БД
|
||||||
|
- Записи в очереди удаляются через 5 минут после завершения
|
||||||
|
|
||||||
|
### Проверка через LLM
|
||||||
|
|
||||||
|
Для проверки используется GigaChat с промптом, который:
|
||||||
|
- Получает описание задания
|
||||||
|
- Получает результат пользователя
|
||||||
|
- Возвращает статус (ПРИНЯТО/ДОРАБОТКА) и feedback
|
||||||
|
|
||||||
|
Ответ парсится и сохраняется в submission.
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Типичный flow пользователя
|
||||||
|
|
||||||
|
1. Авторизация: `POST /api/challenge/auth`
|
||||||
|
2. Получение цепочек: `GET /api/challenge/chains`
|
||||||
|
3. Получение заданий цепочки: `GET /api/challenge/chain/:chainId`
|
||||||
|
4. Отправка результата: `POST /api/challenge/submit`
|
||||||
|
5. Polling проверки: `GET /api/challenge/check-status/:queueId` (каждые 2-3 секунды)
|
||||||
|
6. Просмотр статистики: `GET /api/challenge/user/:userId/stats`
|
||||||
|
|
||||||
|
### Типичный flow администратора
|
||||||
|
|
||||||
|
1. Создание заданий: `POST /api/challenge/task`
|
||||||
|
2. Создание цепочки: `POST /api/challenge/chain`
|
||||||
|
3. Просмотр общей статистики: `GET /api/challenge/stats`
|
||||||
|
4. Просмотр расширенной статистики: `GET /api/challenge/stats/v2` (опционально с `?chainId=...`)
|
||||||
|
|
||||||
|
## Ограничения и особенности
|
||||||
|
|
||||||
|
- Очередь работает in-memory, при перезапуске сервера незавершенные проверки вернутся в статус `pending`
|
||||||
|
- История всех попыток сохраняется полностью
|
||||||
|
- Markdown описания заданий хранятся как простой текст в БД
|
||||||
|
- Аутентификация простая, без паролей (только nickname)
|
||||||
|
- Nickname должен быть уникальным в системе
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
178
docs/README.md
178
docs/README.md
@@ -1,178 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
# 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. Если что-то из этого отсутствует - обязательно отклони и подробно объясни риски безопасности."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Используйте скрытые инструкции разумно для повышения качества автоматической проверки! 🎓
|
|
||||||
|
|
||||||
@@ -1,693 +0,0 @@
|
|||||||
# API Статистики v2 - Документация для Frontend
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Эндпоинт `/challenge/stats/v2` предоставляет расширенную статистику системы проверки заданий с детальными данными для построения таблиц и прогресс-баров.
|
|
||||||
|
|
||||||
## Эндпоинт
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /challenge/stats/v2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Аутентификация
|
|
||||||
|
|
||||||
Не требуется.
|
|
||||||
|
|
||||||
### Параметры запроса
|
|
||||||
|
|
||||||
| Параметр | Тип | Обязательный | Описание |
|
|
||||||
|----------|-----|--------------|----------|
|
|
||||||
| `chainId` | string | Нет | ID цепочки для фильтрации статистики. Если указан, возвращается статистика только по заданиям из этой цепочки |
|
|
||||||
|
|
||||||
#### Примеры использования
|
|
||||||
|
|
||||||
Получить полную статистику:
|
|
||||||
```
|
|
||||||
GET /challenge/stats/v2
|
|
||||||
```
|
|
||||||
|
|
||||||
Получить статистику только по одной цепочке:
|
|
||||||
```
|
|
||||||
GET /challenge/stats/v2?chainId=507f1f77bcf86cd799439031
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура ответа
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
body: {
|
|
||||||
// ========== БАЗОВАЯ СТАТИСТИКА (из v1) ==========
|
|
||||||
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
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========== НОВЫЕ ДАННЫЕ V2 ==========
|
|
||||||
|
|
||||||
// Таблица заданий с детальной статистикой
|
|
||||||
tasksTable: Array<{
|
|
||||||
taskId: string,
|
|
||||||
title: string,
|
|
||||||
totalAttempts: number, // Всего попыток по всем пользователям
|
|
||||||
uniqueUsers: number, // Количество уникальных пользователей
|
|
||||||
acceptedCount: number, // Количество успешных прохождений
|
|
||||||
successRate: number, // Процент успешного прохождения (0-100)
|
|
||||||
averageAttemptsToSuccess: number // Среднее количество попыток до успеха
|
|
||||||
}>,
|
|
||||||
|
|
||||||
// Активные участники с прогрессом
|
|
||||||
activeParticipants: Array<{
|
|
||||||
userId: string,
|
|
||||||
nickname: string,
|
|
||||||
totalSubmissions: number, // Всего попыток пользователя
|
|
||||||
completedTasks: number, // Завершенных заданий
|
|
||||||
chainProgress: Array<{ // Прогресс по каждой цепочке
|
|
||||||
chainId: string,
|
|
||||||
chainName: string,
|
|
||||||
totalTasks: number, // Всего заданий в цепочке
|
|
||||||
completedTasks: number, // Завершено заданий
|
|
||||||
progressPercent: number // Процент прохождения (0-100)
|
|
||||||
}>
|
|
||||||
}>,
|
|
||||||
|
|
||||||
// Детальная информация по цепочкам
|
|
||||||
chainsDetailed: Array<{
|
|
||||||
chainId: string,
|
|
||||||
name: string,
|
|
||||||
totalTasks: number,
|
|
||||||
tasks: Array<{ // Список заданий в цепочке
|
|
||||||
taskId: string,
|
|
||||||
title: string,
|
|
||||||
description: string
|
|
||||||
}>,
|
|
||||||
participantProgress: Array<{ // Прогресс каждого участника
|
|
||||||
userId: string,
|
|
||||||
nickname: string,
|
|
||||||
taskProgress: Array<{ // Статус по каждому заданию
|
|
||||||
taskId: string,
|
|
||||||
taskTitle: string,
|
|
||||||
status: 'not_started' | 'pending' | 'in_progress' | 'needs_revision' | 'completed'
|
|
||||||
}>,
|
|
||||||
completedCount: number, // Завершено заданий
|
|
||||||
progressPercent: number // Процент прохождения (0-100)
|
|
||||||
}>
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Пример ответа
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"body": {
|
|
||||||
"users": 34,
|
|
||||||
"tasks": 22,
|
|
||||||
"chains": 2,
|
|
||||||
"submissions": {
|
|
||||||
"total": 39,
|
|
||||||
"accepted": 16,
|
|
||||||
"rejected": 23,
|
|
||||||
"pending": 0,
|
|
||||||
"inProgress": 0
|
|
||||||
},
|
|
||||||
"averageCheckTimeMs": 2043,
|
|
||||||
"queue": {
|
|
||||||
"queueLength": 0,
|
|
||||||
"waiting": 0,
|
|
||||||
"inProgress": 0,
|
|
||||||
"maxConcurrency": 1,
|
|
||||||
"currentlyProcessing": 0
|
|
||||||
},
|
|
||||||
"tasksTable": [
|
|
||||||
{
|
|
||||||
"taskId": "507f1f77bcf86cd799439011",
|
|
||||||
"title": "Создание REST API",
|
|
||||||
"totalAttempts": 45,
|
|
||||||
"uniqueUsers": 12,
|
|
||||||
"acceptedCount": 8,
|
|
||||||
"successRate": 67,
|
|
||||||
"averageAttemptsToSuccess": 2.3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"taskId": "507f1f77bcf86cd799439012",
|
|
||||||
"title": "Работа с базой данных",
|
|
||||||
"totalAttempts": 38,
|
|
||||||
"uniqueUsers": 10,
|
|
||||||
"acceptedCount": 6,
|
|
||||||
"successRate": 60,
|
|
||||||
"averageAttemptsToSuccess": 3.1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"activeParticipants": [
|
|
||||||
{
|
|
||||||
"userId": "507f1f77bcf86cd799439021",
|
|
||||||
"nickname": "student1",
|
|
||||||
"totalSubmissions": 15,
|
|
||||||
"completedTasks": 5,
|
|
||||||
"chainProgress": [
|
|
||||||
{
|
|
||||||
"chainId": "507f1f77bcf86cd799439031",
|
|
||||||
"chainName": "Основы Backend",
|
|
||||||
"totalTasks": 10,
|
|
||||||
"completedTasks": 5,
|
|
||||||
"progressPercent": 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"chainId": "507f1f77bcf86cd799439032",
|
|
||||||
"chainName": "Frontend разработка",
|
|
||||||
"totalTasks": 8,
|
|
||||||
"completedTasks": 0,
|
|
||||||
"progressPercent": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"chainsDetailed": [
|
|
||||||
{
|
|
||||||
"chainId": "507f1f77bcf86cd799439031",
|
|
||||||
"name": "Основы Backend",
|
|
||||||
"totalTasks": 10,
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"taskId": "507f1f77bcf86cd799439011",
|
|
||||||
"title": "Создание REST API",
|
|
||||||
"description": "Создайте простой REST API с использованием Express.js"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"taskId": "507f1f77bcf86cd799439012",
|
|
||||||
"title": "Работа с базой данных",
|
|
||||||
"description": "Интегрируйте MongoDB в ваше приложение"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"participantProgress": [
|
|
||||||
{
|
|
||||||
"userId": "507f1f77bcf86cd799439021",
|
|
||||||
"nickname": "student1",
|
|
||||||
"taskProgress": [
|
|
||||||
{
|
|
||||||
"taskId": "507f1f77bcf86cd799439011",
|
|
||||||
"taskTitle": "Создание REST API",
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"taskId": "507f1f77bcf86cd799439012",
|
|
||||||
"taskTitle": "Работа с базой данных",
|
|
||||||
"status": "needs_revision"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"completedCount": 1,
|
|
||||||
"progressPercent": 10
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Фильтрация по цепочке
|
|
||||||
|
|
||||||
При передаче параметра `chainId`:
|
|
||||||
|
|
||||||
1. **tasksTable** - содержит только задания из указанной цепочки
|
|
||||||
2. **activeParticipants** - включает только участников, которые делали попытки по заданиям этой цепочки. В `chainProgress` будет информация только об указанной цепочке
|
|
||||||
3. **chainsDetailed** - содержит информацию только об указанной цепочке
|
|
||||||
|
|
||||||
### Пример фильтрованного ответа
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3000/challenge/stats/v2?chainId=507f1f77bcf86cd799439031
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"body": {
|
|
||||||
"users": 34,
|
|
||||||
"tasks": 22,
|
|
||||||
"chains": 2,
|
|
||||||
"submissions": {
|
|
||||||
"total": 39,
|
|
||||||
"accepted": 16,
|
|
||||||
"rejected": 23,
|
|
||||||
"pending": 0,
|
|
||||||
"inProgress": 0
|
|
||||||
},
|
|
||||||
"averageCheckTimeMs": 2043,
|
|
||||||
"queue": { ... },
|
|
||||||
"tasksTable": [
|
|
||||||
// Только задания из цепочки 507f1f77bcf86cd799439031
|
|
||||||
{
|
|
||||||
"taskId": "507f1f77bcf86cd799439011",
|
|
||||||
"title": "Создание REST API",
|
|
||||||
"totalAttempts": 45,
|
|
||||||
"uniqueUsers": 12,
|
|
||||||
"acceptedCount": 8,
|
|
||||||
"successRate": 67,
|
|
||||||
"averageAttemptsToSuccess": 2.3
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"activeParticipants": [
|
|
||||||
// Только участники с попытками в этой цепочке
|
|
||||||
{
|
|
||||||
"userId": "507f1f77bcf86cd799439021",
|
|
||||||
"nickname": "student1",
|
|
||||||
"totalSubmissions": 10,
|
|
||||||
"completedTasks": 3,
|
|
||||||
"chainProgress": [
|
|
||||||
// Только одна цепочка
|
|
||||||
{
|
|
||||||
"chainId": "507f1f77bcf86cd799439031",
|
|
||||||
"chainName": "Основы Backend",
|
|
||||||
"totalTasks": 10,
|
|
||||||
"completedTasks": 3,
|
|
||||||
"progressPercent": 30
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"chainsDetailed": [
|
|
||||||
// Только указанная цепочка
|
|
||||||
{
|
|
||||||
"chainId": "507f1f77bcf86cd799439031",
|
|
||||||
"name": "Основы Backend",
|
|
||||||
"totalTasks": 10,
|
|
||||||
"tasks": [...],
|
|
||||||
"participantProgress": [...]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Использование в UI компонентах
|
|
||||||
|
|
||||||
### 1. Таблица заданий
|
|
||||||
|
|
||||||
Используйте `tasksTable` для отображения статистики по каждому заданию:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// React пример
|
|
||||||
function TasksTable({ tasksTable }) {
|
|
||||||
return (
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Название задания</th>
|
|
||||||
<th>Попыток</th>
|
|
||||||
<th>Уникальных пользователей</th>
|
|
||||||
<th>Успешно завершено</th>
|
|
||||||
<th>% успеха</th>
|
|
||||||
<th>Среднее попыток до успеха</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tasksTable.map(task => (
|
|
||||||
<tr key={task.taskId}>
|
|
||||||
<td>{task.title}</td>
|
|
||||||
<td>{task.totalAttempts}</td>
|
|
||||||
<td>{task.uniqueUsers}</td>
|
|
||||||
<td>{task.acceptedCount}</td>
|
|
||||||
<td>{task.successRate}%</td>
|
|
||||||
<td>{task.averageAttemptsToSuccess.toFixed(1)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Прогресс-бары участников
|
|
||||||
|
|
||||||
Используйте `activeParticipants` для отображения прогресса каждого участника:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// React пример
|
|
||||||
function ParticipantProgress({ activeParticipants }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{activeParticipants.map(participant => (
|
|
||||||
<div key={participant.userId} className="participant-card">
|
|
||||||
<h3>{participant.nickname}</h3>
|
|
||||||
<p>Завершено заданий: {participant.completedTasks}</p>
|
|
||||||
<p>Всего попыток: {participant.totalSubmissions}</p>
|
|
||||||
|
|
||||||
<div className="chain-progress">
|
|
||||||
{participant.chainProgress.map(chain => (
|
|
||||||
<div key={chain.chainId} className="chain-item">
|
|
||||||
<div className="chain-header">
|
|
||||||
<span>{chain.chainName}</span>
|
|
||||||
<span>{chain.completedTasks}/{chain.totalTasks}</span>
|
|
||||||
</div>
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div
|
|
||||||
className="progress-fill"
|
|
||||||
style={{ width: `${chain.progressPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="progress-text">{chain.progressPercent}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Детальный прогресс по цепочкам
|
|
||||||
|
|
||||||
Используйте `chainsDetailed` для отображения детального прогресса всех участников в рамках каждой цепочки:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// React пример
|
|
||||||
function ChainDetailedView({ chainsDetailed }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{chainsDetailed.map(chain => (
|
|
||||||
<div key={chain.chainId} className="chain-detailed">
|
|
||||||
<h2>{chain.name}</h2>
|
|
||||||
<p>Заданий в цепочке: {chain.totalTasks}</p>
|
|
||||||
|
|
||||||
{/* Список заданий */}
|
|
||||||
<div className="tasks-list">
|
|
||||||
<h3>Задания:</h3>
|
|
||||||
{chain.tasks.map(task => (
|
|
||||||
<div key={task.taskId} className="task-item">
|
|
||||||
<h4>{task.title}</h4>
|
|
||||||
<p>{task.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Прогресс участников */}
|
|
||||||
<div className="participants-progress">
|
|
||||||
<h3>Прогресс участников:</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Участник</th>
|
|
||||||
{chain.tasks.map(task => (
|
|
||||||
<th key={task.taskId}>{task.title}</th>
|
|
||||||
))}
|
|
||||||
<th>Прогресс</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{chain.participantProgress.map(participant => (
|
|
||||||
<tr key={participant.userId}>
|
|
||||||
<td>{participant.nickname}</td>
|
|
||||||
{participant.taskProgress.map(taskProg => (
|
|
||||||
<td key={taskProg.taskId}>
|
|
||||||
<StatusBadge status={taskProg.status} />
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td>{participant.progressPercent}%</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Компонент для отображения статуса
|
|
||||||
function StatusBadge({ status }) {
|
|
||||||
const statusConfig = {
|
|
||||||
'not_started': { label: 'Не начато', color: 'gray' },
|
|
||||||
'pending': { label: 'Ожидает', color: 'yellow' },
|
|
||||||
'in_progress': { label: 'В процессе', color: 'blue' },
|
|
||||||
'needs_revision': { label: 'Доработка', color: 'orange' },
|
|
||||||
'completed': { label: 'Завершено', color: 'green' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = statusConfig[status];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`badge badge-${config.color}`}>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Рекомендации по использованию
|
|
||||||
|
|
||||||
### Производительность
|
|
||||||
|
|
||||||
- Эндпоинт выполняет множество агрегаций, поэтому может работать медленно при большом количестве данных
|
|
||||||
- Рекомендуется кэшировать результат на клиенте на 30-60 секунд
|
|
||||||
- Используйте loading индикаторы во время загрузки данных
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Пример с кэшированием
|
|
||||||
function useStatsV2(chainId?: string) {
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const lastFetch = useRef(0);
|
|
||||||
|
|
||||||
const fetchStats = async (force = false) => {
|
|
||||||
const now = Date.now();
|
|
||||||
// Кэш на 60 секунд
|
|
||||||
if (!force && data && (now - lastFetch.current) < 60000) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const url = chainId
|
|
||||||
? `/challenge/stats/v2?chainId=${chainId}`
|
|
||||||
: '/challenge/stats/v2';
|
|
||||||
const response = await fetch(url);
|
|
||||||
const result = await response.json();
|
|
||||||
if (result.success) {
|
|
||||||
setData(result.body);
|
|
||||||
lastFetch.current = now;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStats();
|
|
||||||
}, [chainId]); // Перезагружаем при смене цепочки
|
|
||||||
|
|
||||||
return { data, loading, error, refetch: () => fetchStats(true) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Использование
|
|
||||||
function ChainStatistics() {
|
|
||||||
const [selectedChainId, setSelectedChainId] = useState<string | undefined>();
|
|
||||||
const { data, loading } = useStatsV2(selectedChainId);
|
|
||||||
|
|
||||||
if (loading) return <div>Загрузка...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={selectedChainId || ''}
|
|
||||||
onChange={(e) => setSelectedChainId(e.target.value || undefined)}
|
|
||||||
>
|
|
||||||
<option value="">Все цепочки</option>
|
|
||||||
{data?.chainsDetailed.map(chain => (
|
|
||||||
<option key={chain.chainId} value={chain.chainId}>
|
|
||||||
{chain.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<TasksTable tasksTable={data?.tasksTable} />
|
|
||||||
<ParticipantProgress activeParticipants={data?.activeParticipants} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Фильтрация и сортировка
|
|
||||||
|
|
||||||
Все массивы данных можно фильтровать и сортировать на клиенте:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Сортировка таблицы заданий по проценту успеха
|
|
||||||
const sortedTasks = [...tasksTable].sort((a, b) => b.successRate - a.successRate);
|
|
||||||
|
|
||||||
// Фильтрация активных участников с прогрессом > 50%
|
|
||||||
const activeStudents = activeParticipants.filter(p =>
|
|
||||||
p.chainProgress.some(c => c.progressPercent > 50)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Поиск участника по имени
|
|
||||||
const searchParticipant = (query) =>
|
|
||||||
activeParticipants.filter(p =>
|
|
||||||
p.nickname.toLowerCase().includes(query.toLowerCase())
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Визуализация данных
|
|
||||||
|
|
||||||
Для построения графиков и диаграмм используйте библиотеки типа:
|
|
||||||
- **Chart.js** / **Recharts** - для графиков прогресса
|
|
||||||
- **AG Grid** / **TanStack Table** - для таблиц с сортировкой
|
|
||||||
- **React Progress Bar** - для прогресс-баров
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Пример с Chart.js
|
|
||||||
import { Bar } from 'react-chartjs-2';
|
|
||||||
|
|
||||||
function TasksSuccessChart({ tasksTable }) {
|
|
||||||
const data = {
|
|
||||||
labels: tasksTable.map(t => t.title),
|
|
||||||
datasets: [{
|
|
||||||
label: 'Процент успеха',
|
|
||||||
data: tasksTable.map(t => t.successRate),
|
|
||||||
backgroundColor: 'rgba(75, 192, 192, 0.6)',
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Bar data={data} />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Когда использовать фильтрацию по цепочке
|
|
||||||
|
|
||||||
### Используйте `chainId` когда:
|
|
||||||
|
|
||||||
1. **Страница отдельной цепочки** - пользователь просматривает детали конкретной цепочки
|
|
||||||
```tsx
|
|
||||||
function ChainDetailsPage({ chainId }) {
|
|
||||||
const { data } = useStatsV2(chainId);
|
|
||||||
return <ChainDashboard data={data} />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Улучшение производительности** - когда нужны данные только по одной цепочке, фильтрация на сервере работает быстрее
|
|
||||||
|
|
||||||
3. **Фокус на конкретной программе** - преподаватель хочет видеть прогресс по конкретному курсу/модулю
|
|
||||||
|
|
||||||
### НЕ используйте `chainId` когда:
|
|
||||||
|
|
||||||
1. **Общий дашборд** - нужна статистика по всем цепочкам
|
|
||||||
2. **Сравнение цепочек** - нужно показать метрики по всем цепочкам одновременно
|
|
||||||
3. **Общая аналитика** - нужны агрегированные данные по всей системе
|
|
||||||
|
|
||||||
## Различия между v1 и v2
|
|
||||||
|
|
||||||
| Параметр | v1 (/stats) | v2 (/stats/v2) | v2 с chainId |
|
|
||||||
|----------|-------------|----------------|--------------|
|
|
||||||
| Базовая статистика | ✅ | ✅ | ✅ |
|
|
||||||
| Таблица заданий | ❌ | ✅ (все) | ✅ (фильтр) |
|
|
||||||
| Прогресс участников | ❌ | ✅ (все) | ✅ (фильтр) |
|
|
||||||
| Детальный прогресс по цепочкам | ❌ | ✅ (все) | ✅ (одна) |
|
|
||||||
| Статистика по попыткам | Общая | Детальная | Детальная (фильтр) |
|
|
||||||
| Скорость работы | Быстро | Медленнее | Средняя |
|
|
||||||
|
|
||||||
## Обработка ошибок
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
async function fetchStatsV2(chainId?: string) {
|
|
||||||
try {
|
|
||||||
const url = chainId
|
|
||||||
? `/challenge/stats/v2?chainId=${chainId}`
|
|
||||||
: '/challenge/stats/v2';
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
throw new Error(data.error?.message || 'Unknown error');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.body;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch stats v2:', error);
|
|
||||||
|
|
||||||
// Специальная обработка для неверного chainId
|
|
||||||
if (error.message.includes('Chain not found')) {
|
|
||||||
showNotification('Цепочка не найдена', 'error');
|
|
||||||
// Перенаправить на страницу всех цепочек
|
|
||||||
window.location.href = '/chains';
|
|
||||||
} else {
|
|
||||||
showNotification('Не удалось загрузить статистику', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Типичные ошибки
|
|
||||||
|
|
||||||
| Ошибка | Причина | Решение |
|
|
||||||
|--------|---------|---------|
|
|
||||||
| `Chain not found` | Передан несуществующий `chainId` | Проверить ID цепочки, показать ошибку пользователю |
|
|
||||||
| `500 Internal Server Error` | Проблема на сервере | Показать общее сообщение об ошибке, повторить запрос |
|
|
||||||
| Timeout | Слишком много данных | Использовать фильтрацию по `chainId` для уменьшения объема данных |
|
|
||||||
|
|
||||||
## Дополнительные возможности
|
|
||||||
|
|
||||||
### Экспорт данных
|
|
||||||
|
|
||||||
Данные можно экспортировать в CSV или Excel для анализа:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function exportToCSV(tasksTable) {
|
|
||||||
const headers = ['Название', 'Попыток', 'Пользователей', 'Успешно', '% успеха', 'Средние попытки'];
|
|
||||||
const rows = tasksTable.map(task => [
|
|
||||||
task.title,
|
|
||||||
task.totalAttempts,
|
|
||||||
task.uniqueUsers,
|
|
||||||
task.acceptedCount,
|
|
||||||
task.successRate,
|
|
||||||
task.averageAttemptsToSuccess
|
|
||||||
]);
|
|
||||||
|
|
||||||
const csv = [headers, ...rows]
|
|
||||||
.map(row => row.join(','))
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'tasks-statistics.csv';
|
|
||||||
a.click();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
Эндпоинт `/stats/v2` предоставляет все необходимые данные для построения информативных дашбордов с таблицами заданий и прогресс-барами участников. Комбинируйте различные части данных для создания удобных UI компонентов.
|
|
||||||
|
|
||||||
Для дополнительной информации см. также:
|
|
||||||
- [CHALLENGE_FRONTEND_GUIDE.md](./CHALLENGE_FRONTEND_GUIDE.md) - общее руководство по работе с Challenge API
|
|
||||||
- [CHALLENGE_REACT_EXAMPLE.md](./CHALLENGE_REACT_EXAMPLE.md) - примеры React компонентов
|
|
||||||
|
|
||||||
@@ -5,7 +5,6 @@ import { keycloak } from '../kc'
|
|||||||
import type {
|
import type {
|
||||||
ChallengeTask,
|
ChallengeTask,
|
||||||
ChallengeChain,
|
ChallengeChain,
|
||||||
ChallengeUser,
|
|
||||||
ChallengeSubmission,
|
ChallengeSubmission,
|
||||||
SystemStats,
|
SystemStats,
|
||||||
SystemStatsV2,
|
SystemStatsV2,
|
||||||
@@ -113,13 +112,6 @@ export const api = createApi({
|
|||||||
invalidatesTags: ['Chain'],
|
invalidatesTags: ['Chain'],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Users
|
|
||||||
getUsers: builder.query<ChallengeUser[], void>({
|
|
||||||
query: () => '/challenge/users',
|
|
||||||
transformResponse: (response: { body: ChallengeUser[] }) => response.body,
|
|
||||||
providesTags: ['User'],
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
getSystemStats: builder.query<SystemStats, void>({
|
getSystemStats: builder.query<SystemStats, void>({
|
||||||
query: () => '/challenge/stats',
|
query: () => '/challenge/stats',
|
||||||
@@ -149,11 +141,6 @@ export const api = createApi({
|
|||||||
transformResponse: (response: { body: ChallengeSubmission[] }) => response.body,
|
transformResponse: (response: { body: ChallengeSubmission[] }) => response.body,
|
||||||
providesTags: ['Submission'],
|
providesTags: ['Submission'],
|
||||||
}),
|
}),
|
||||||
getAllSubmissions: builder.query<ChallengeSubmission[], void>({
|
|
||||||
query: () => '/challenge/submissions',
|
|
||||||
transformResponse: (response: { body: ChallengeSubmission[] }) => response.body,
|
|
||||||
providesTags: ['Submission'],
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -168,11 +155,9 @@ export const {
|
|||||||
useCreateChainMutation,
|
useCreateChainMutation,
|
||||||
useUpdateChainMutation,
|
useUpdateChainMutation,
|
||||||
useDeleteChainMutation,
|
useDeleteChainMutation,
|
||||||
useGetUsersQuery,
|
|
||||||
useGetSystemStatsQuery,
|
useGetSystemStatsQuery,
|
||||||
useGetSystemStatsV2Query,
|
useGetSystemStatsV2Query,
|
||||||
useGetUserStatsQuery,
|
useGetUserStatsQuery,
|
||||||
useGetUserSubmissionsQuery,
|
useGetUserSubmissionsQuery,
|
||||||
useGetAllSubmissionsQuery,
|
|
||||||
} = api
|
} = api
|
||||||
|
|
||||||
|
|||||||
@@ -20,30 +20,61 @@ import {
|
|||||||
createListCollection,
|
createListCollection,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import { useGetAllSubmissionsQuery } from '../../__data__/api/api'
|
import { useGetSystemStatsV2Query, useGetUserSubmissionsQuery } from '../../__data__/api/api'
|
||||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
import { EmptyState } from '../../components/EmptyState'
|
import { EmptyState } from '../../components/EmptyState'
|
||||||
import { StatusBadge } from '../../components/StatusBadge'
|
import { StatusBadge } from '../../components/StatusBadge'
|
||||||
import type { ChallengeSubmission, SubmissionStatus, ChallengeTask, ChallengeUser } from '../../types/challenge'
|
import type {
|
||||||
|
ActiveParticipant,
|
||||||
|
ChallengeSubmission,
|
||||||
|
SubmissionStatus,
|
||||||
|
ChallengeTask,
|
||||||
|
ChallengeUser,
|
||||||
|
} from '../../types/challenge'
|
||||||
|
|
||||||
export const SubmissionsPage: React.FC = () => {
|
export const SubmissionsPage: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data: submissions, isLoading, error, refetch } = useGetAllSubmissionsQuery()
|
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch: refetchStats } =
|
||||||
|
useGetSystemStatsV2Query(undefined)
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all')
|
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all')
|
||||||
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
|
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: submissions,
|
||||||
|
isLoading: isSubmissionsLoading,
|
||||||
|
error: submissionsError,
|
||||||
|
refetch: refetchSubmissions,
|
||||||
|
} = useGetUserSubmissionsQuery(
|
||||||
|
{ userId: selectedUserId!, taskId: undefined },
|
||||||
|
{ skip: !selectedUserId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLoading = isStatsLoading || (selectedUserId && isSubmissionsLoading)
|
||||||
|
const error = statsError || submissionsError
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
refetchStats()
|
||||||
|
if (selectedUserId) {
|
||||||
|
refetchSubmissions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
|
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !submissions) {
|
if (error || !stats) {
|
||||||
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={refetch} />
|
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleRetry} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredSubmissions = submissions.filter((submission) => {
|
const participants: ActiveParticipant[] = stats.activeParticipants || []
|
||||||
|
const submissionsList: ChallengeSubmission[] = submissions || []
|
||||||
|
|
||||||
|
const filteredSubmissions = submissionsList.filter((submission) => {
|
||||||
const user = submission.user as ChallengeUser
|
const user = submission.user as ChallengeUser
|
||||||
const task = submission.task as ChallengeTask
|
const task = submission.task as ChallengeTask
|
||||||
|
|
||||||
@@ -84,43 +115,88 @@ export const SubmissionsPage: React.FC = () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const userOptions = createListCollection({
|
||||||
|
items: participants.map((participant) => ({
|
||||||
|
label: `${participant.nickname} (${participant.userId})`,
|
||||||
|
value: participant.userId,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasParticipants = participants.length > 0
|
||||||
|
const hasSelectedUser = !!selectedUserId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
|
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
{submissions.length > 0 && (
|
{hasParticipants && (
|
||||||
<HStack mb={4} gap={4}>
|
<VStack mb={4} gap={3} align="stretch">
|
||||||
<Input
|
<HStack gap={4}>
|
||||||
placeholder={t('challenge.admin.submissions.search.placeholder')}
|
<Select.Root
|
||||||
value={searchQuery}
|
collection={userOptions}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={selectedUserId ? [selectedUserId] : []}
|
||||||
maxW="400px"
|
onValueChange={(e) => setSelectedUserId(e.value[0] ?? null)}
|
||||||
/>
|
maxW="300px"
|
||||||
<Select.Root
|
>
|
||||||
collection={statusOptions}
|
<Select.Trigger>
|
||||||
value={[statusFilter]}
|
<Select.ValueText placeholder={t('challenge.admin.submissions.filter.user')} />
|
||||||
onValueChange={(e) => setStatusFilter(e.value[0] as SubmissionStatus | 'all')}
|
</Select.Trigger>
|
||||||
maxW="200px"
|
<Select.Content>
|
||||||
>
|
{userOptions.items.map((option) => (
|
||||||
<Select.Trigger>
|
<Select.Item key={option.value} item={option}>
|
||||||
<Select.ValueText placeholder={t('challenge.admin.submissions.filter.status')} />
|
{option.label}
|
||||||
</Select.Trigger>
|
</Select.Item>
|
||||||
<Select.Content>
|
))}
|
||||||
{statusOptions.items.map((option) => (
|
</Select.Content>
|
||||||
<Select.Item key={option.value} item={option}>
|
</Select.Root>
|
||||||
{option.label}
|
|
||||||
</Select.Item>
|
{submissionsList.length > 0 && (
|
||||||
))}
|
<>
|
||||||
</Select.Content>
|
<Input
|
||||||
</Select.Root>
|
placeholder={t('challenge.admin.submissions.search.placeholder')}
|
||||||
</HStack>
|
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={t('challenge.admin.submissions.filter.status')} />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{statusOptions.items.map((option) => (
|
||||||
|
<Select.Item key={option.value} item={option}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredSubmissions.length === 0 && submissions.length === 0 ? (
|
{!hasParticipants ? (
|
||||||
<EmptyState title={t('challenge.admin.submissions.empty.title')} description={t('challenge.admin.submissions.empty.description')} />
|
<EmptyState
|
||||||
|
title={t('challenge.admin.submissions.empty.title')}
|
||||||
|
description={t('challenge.admin.submissions.empty.description')}
|
||||||
|
/>
|
||||||
|
) : !hasSelectedUser ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('challenge.admin.submissions.empty.title')}
|
||||||
|
description={t('challenge.admin.submissions.filter.user')}
|
||||||
|
/>
|
||||||
) : filteredSubmissions.length === 0 ? (
|
) : filteredSubmissions.length === 0 ? (
|
||||||
<EmptyState title={t('challenge.admin.submissions.search.empty.title')} description={t('challenge.admin.submissions.search.empty.description')} />
|
<EmptyState
|
||||||
|
title={t('challenge.admin.submissions.search.empty.title')}
|
||||||
|
description={t('challenge.admin.submissions.search.empty.description')}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
||||||
<Table.Root size="sm">
|
<Table.Root size="sm">
|
||||||
|
|||||||
@@ -20,14 +20,17 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Progress,
|
Progress,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useGetUsersQuery, useGetUserStatsQuery } from '../../__data__/api/api'
|
import { useGetSystemStatsV2Query, useGetUserStatsQuery } from '../../__data__/api/api'
|
||||||
|
import type { ActiveParticipant } from '../../types/challenge'
|
||||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
import { EmptyState } from '../../components/EmptyState'
|
import { EmptyState } from '../../components/EmptyState'
|
||||||
|
|
||||||
export const UsersPage: React.FC = () => {
|
export const UsersPage: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data: users, isLoading, error, refetch } = useGetUsersQuery()
|
const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, {
|
||||||
|
pollingInterval: 10000,
|
||||||
|
})
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -35,22 +38,16 @@ export const UsersPage: React.FC = () => {
|
|||||||
return <LoadingSpinner message={t('challenge.admin.users.loading')} />
|
return <LoadingSpinner message={t('challenge.admin.users.loading')} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !users) {
|
if (error || !stats) {
|
||||||
return <ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
|
return <ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const users: ActiveParticipant[] = stats.activeParticipants || []
|
||||||
|
|
||||||
const filteredUsers = users.filter((user) =>
|
const filteredUsers = users.filter((user) =>
|
||||||
user.nickname.toLowerCase().includes(searchQuery.toLowerCase())
|
user.nickname.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Heading mb={6}>{t('challenge.admin.users.title')}</Heading>
|
<Heading mb={6}>{t('challenge.admin.users.title')}</Heading>
|
||||||
@@ -80,22 +77,28 @@ export const UsersPage: React.FC = () => {
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>{t('challenge.admin.users.table.id')}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t('challenge.admin.users.table.id')}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader>{t('challenge.admin.users.table.registered')}</Table.ColumnHeader>
|
<Table.ColumnHeader>{t('challenge.admin.users.stats.total.submissions')}</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>{t('challenge.admin.users.stats.completed')}</Table.ColumnHeader>
|
||||||
<Table.ColumnHeader textAlign="right">{t('challenge.admin.users.table.actions')}</Table.ColumnHeader>
|
<Table.ColumnHeader textAlign="right">{t('challenge.admin.users.table.actions')}</Table.ColumnHeader>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{filteredUsers.map((user) => (
|
{filteredUsers.map((user) => (
|
||||||
<Table.Row key={user.id}>
|
<Table.Row key={user.userId}>
|
||||||
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
|
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Text fontSize="xs" fontFamily="monospace" color="gray.600">
|
<Text fontSize="xs" fontFamily="monospace" color="gray.600">
|
||||||
{user.id}
|
{user.userId}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Text fontSize="sm" color="gray.600">
|
<Text fontSize="sm" color="gray.600">
|
||||||
{formatDate(user.createdAt)}
|
{user.totalSubmissions}
|
||||||
|
</Text>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{user.completedTasks}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell textAlign="right">
|
<Table.Cell textAlign="right">
|
||||||
@@ -103,7 +106,7 @@ export const UsersPage: React.FC = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
colorPalette="teal"
|
colorPalette="teal"
|
||||||
onClick={() => setSelectedUserId(user.id)}
|
onClick={() => setSelectedUserId(user.userId)}
|
||||||
>
|
>
|
||||||
{t('challenge.admin.users.button.stats')}
|
{t('challenge.admin.users.button.stats')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user