1126 lines
33 KiB
Markdown
1126 lines
33 KiB
Markdown
# Challenge Service - Руководство для фронтенда
|
||
|
||
Руководство по интеграции сервиса проверки заданий через LLM в пользовательский интерфейс.
|
||
|
||
## Содержание
|
||
|
||
1. [Основные сценарии использования](#основные-сценарии-использования)
|
||
2. [Структура данных](#структура-данных)
|
||
3. [API взаимодействие](#api-взаимодействие)
|
||
4. [Компоненты UI](#компоненты-ui)
|
||
5. [State Management](#state-management)
|
||
6. [Best Practices](#best-practices)
|
||
|
||
---
|
||
|
||
## Основные сценарии использования
|
||
|
||
### 1. Сценарий для студента
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 1. Вход/Регистрация (nickname) │
|
||
│ POST /api/challenge/auth │
|
||
│ → Сохранить userId в localStorage/state │
|
||
└────────────────┬────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────▼────────────────────────────────────────────┐
|
||
│ 2. Просмотр доступных цепочек заданий │
|
||
│ GET /api/challenge/chains │
|
||
│ → Отобразить список с прогрессом │
|
||
└────────────────┬────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────▼────────────────────────────────────────────┐
|
||
│ 3. Выбор задания из цепочки │
|
||
│ → Отобразить описание (Markdown) │
|
||
│ → Показать историю своих попыток │
|
||
└────────────────┬────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────▼────────────────────────────────────────────┐
|
||
│ 4. Написание решения │
|
||
│ → Текстовое поле / Code Editor │
|
||
│ → Кнопка "Отправить на проверку" │
|
||
└────────────────┬────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────▼────────────────────────────────────────────┐
|
||
│ 5. Отправка на проверку │
|
||
│ POST /api/challenge/submit │
|
||
│ → Получить queueId │
|
||
└────────────────┬────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────▼────────────────────────────────────────────┐
|
||
│ 6. Ожидание результата (polling) │
|
||
│ GET /api/challenge/check-status/:queueId │
|
||
│ → Показать индикатор загрузки + позицию в очереди │
|
||
│ → Повторять каждые 2-3 секунды │
|
||
└────────────────┬────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────▼────────────────────────────────────────────┐
|
||
│ 7. Получение результата │
|
||
│ ✅ ПРИНЯТО → Поздравление + переход к следующему │
|
||
│ ❌ ДОРАБОТКА → Feedback от LLM + возможность повторить │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2. Сценарий для администратора
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 1. Создание заданий │
|
||
│ POST /api/challenge/task │
|
||
│ → Markdown редактор для описания │
|
||
└────────────────┬────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────▼────────────────────────────────────────────┐
|
||
│ 2. Создание цепочек │
|
||
│ POST /api/challenge/chain │
|
||
│ → Выбор заданий + порядок │
|
||
└────────────────┬────────────────────────────────────────────┘
|
||
│
|
||
┌────────────────▼────────────────────────────────────────────┐
|
||
│ 3. Мониторинг статистики │
|
||
│ GET /api/challenge/stats │
|
||
│ → Dashboard с метриками │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Структура данных
|
||
|
||
### User (Пользователь)
|
||
|
||
```typescript
|
||
interface ChallengeUser {
|
||
_id: string
|
||
id: string
|
||
nickname: string
|
||
createdAt: string // ISO 8601
|
||
}
|
||
```
|
||
|
||
### Task (Задание)
|
||
|
||
```typescript
|
||
interface ChallengeTask {
|
||
_id: string
|
||
id: string
|
||
title: string
|
||
description: string // Markdown (видно всем)
|
||
hiddenInstructions?: string // Скрытые инструкции для LLM (только для преподавателей)
|
||
creator?: object // Данные создателя (только для преподавателей)
|
||
createdAt: string
|
||
updatedAt: string
|
||
}
|
||
```
|
||
|
||
**Важно:** Поля `hiddenInstructions` и `creator` доступны только пользователям с ролью `teacher` в Keycloak. При запросе заданий обычными студентами эти поля будут отфильтрованы на сервере.
|
||
|
||
### Chain (Цепочка заданий)
|
||
|
||
```typescript
|
||
interface ChallengeChain {
|
||
_id: string
|
||
id: string
|
||
name: string
|
||
tasks: ChallengeTask[] // Populated
|
||
createdAt: string
|
||
updatedAt: string
|
||
}
|
||
```
|
||
|
||
### Submission (Попытка)
|
||
|
||
```typescript
|
||
type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision'
|
||
|
||
interface ChallengeSubmission {
|
||
_id: string
|
||
id: string
|
||
user: ChallengeUser | string // Populated или ID
|
||
task: ChallengeTask | string // Populated или ID
|
||
result: string // Результат пользователя
|
||
status: SubmissionStatus
|
||
queueId?: string
|
||
feedback?: string // Комментарий от LLM
|
||
submittedAt: string
|
||
checkedAt?: string
|
||
attemptNumber: number
|
||
}
|
||
```
|
||
|
||
### Queue Status (Статус проверки)
|
||
|
||
```typescript
|
||
type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found'
|
||
|
||
interface QueueStatus {
|
||
status: QueueStatusType
|
||
submission?: ChallengeSubmission
|
||
error?: string
|
||
position?: number // Позиция в очереди (если waiting)
|
||
}
|
||
```
|
||
|
||
### User Stats (Статистика пользователя)
|
||
|
||
```typescript
|
||
interface TaskStats {
|
||
taskId: string
|
||
taskTitle: string
|
||
attempts: Array<{
|
||
attemptNumber: number
|
||
status: SubmissionStatus
|
||
submittedAt: string
|
||
checkedAt?: string
|
||
feedback?: string
|
||
}>
|
||
totalAttempts: number
|
||
status: 'not_attempted' | 'pending' | 'in_progress' | 'completed' | 'needs_revision'
|
||
lastAttemptAt: string | null
|
||
}
|
||
|
||
interface ChainStats {
|
||
chainId: string
|
||
chainName: string
|
||
totalTasks: number
|
||
completedTasks: number
|
||
progress: number // 0-100
|
||
}
|
||
|
||
interface UserStats {
|
||
totalTasksAttempted: number
|
||
completedTasks: number
|
||
inProgressTasks: number
|
||
needsRevisionTasks: number
|
||
totalSubmissions: number
|
||
averageCheckTimeMs: number
|
||
taskStats: TaskStats[]
|
||
chainStats: ChainStats[]
|
||
}
|
||
```
|
||
|
||
### System Stats (Общая статистика)
|
||
|
||
```typescript
|
||
interface SystemStats {
|
||
users: number
|
||
tasks: number
|
||
chains: number
|
||
submissions: {
|
||
total: number
|
||
accepted: number
|
||
rejected: number
|
||
pending: number
|
||
inProgress: number
|
||
}
|
||
averageCheckTimeMs: number
|
||
queue: {
|
||
queueLength: number
|
||
waiting: number
|
||
inProgress: number
|
||
maxConcurrency: number
|
||
currentlyProcessing: number
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## API взаимодействие
|
||
|
||
### Базовая настройка
|
||
|
||
```typescript
|
||
const API_BASE_URL = 'http://localhost:8082/api/challenge'
|
||
|
||
// Универсальная функция для запросов
|
||
async function apiRequest<T>(
|
||
endpoint: string,
|
||
options: RequestInit = {}
|
||
): Promise<{ error: any; data: T }> {
|
||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...options.headers,
|
||
},
|
||
...options,
|
||
})
|
||
|
||
return response.json()
|
||
}
|
||
```
|
||
|
||
### Примеры использования
|
||
|
||
#### 1. Аутентификация
|
||
|
||
```typescript
|
||
async function authenticateUser(nickname: string): Promise<string> {
|
||
const { data, error } = await apiRequest<{ ok: boolean; userId: string }>(
|
||
'/auth',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({ nickname }),
|
||
}
|
||
)
|
||
|
||
if (error) throw error
|
||
|
||
// Сохраняем userId
|
||
localStorage.setItem('challengeUserId', data.userId)
|
||
|
||
return data.userId
|
||
}
|
||
```
|
||
|
||
#### 2. Получение цепочек
|
||
|
||
```typescript
|
||
async function getChains(): Promise<ChallengeChain[]> {
|
||
const { data, error } = await apiRequest<ChallengeChain[]>('/chains')
|
||
|
||
if (error) throw error
|
||
|
||
return data
|
||
}
|
||
```
|
||
|
||
#### 3. Отправка решения
|
||
|
||
```typescript
|
||
async function submitSolution(
|
||
userId: string,
|
||
taskId: string,
|
||
result: string
|
||
): Promise<{ queueId: string; submissionId: string }> {
|
||
const { data, error } = await apiRequest<{
|
||
queueId: string
|
||
submissionId: string
|
||
}>('/submit', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId, taskId, result }),
|
||
})
|
||
|
||
if (error) throw error
|
||
|
||
return data
|
||
}
|
||
```
|
||
|
||
#### 4. Polling проверки
|
||
|
||
```typescript
|
||
async function pollCheckStatus(
|
||
queueId: string,
|
||
onUpdate: (status: QueueStatus) => void,
|
||
interval: number = 2000
|
||
): Promise<ChallengeSubmission> {
|
||
return new Promise((resolve, reject) => {
|
||
const checkStatus = async () => {
|
||
try {
|
||
const { data, error } = await apiRequest<QueueStatus>(
|
||
`/check-status/${queueId}`
|
||
)
|
||
|
||
if (error) {
|
||
reject(error)
|
||
return
|
||
}
|
||
|
||
onUpdate(data)
|
||
|
||
if (data.status === 'completed' && data.submission) {
|
||
resolve(data.submission)
|
||
} else if (data.status === 'error') {
|
||
reject(new Error(data.error || 'Check failed'))
|
||
} else {
|
||
// Продолжаем polling
|
||
setTimeout(checkStatus, interval)
|
||
}
|
||
} catch (err) {
|
||
reject(err)
|
||
}
|
||
}
|
||
|
||
checkStatus()
|
||
})
|
||
}
|
||
```
|
||
|
||
#### 5. Получение статистики пользователя
|
||
|
||
```typescript
|
||
async function getUserStats(userId: string): Promise<UserStats> {
|
||
const { data, error } = await apiRequest<UserStats>(`/user/${userId}/stats`)
|
||
|
||
if (error) throw error
|
||
|
||
return data
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Компоненты UI
|
||
|
||
### 1. AuthForm - Форма входа
|
||
|
||
```typescript
|
||
// AuthForm.tsx
|
||
import { useState } from 'react'
|
||
|
||
interface AuthFormProps {
|
||
onAuth: (userId: string, nickname: string) => void
|
||
}
|
||
|
||
export function AuthForm({ onAuth }: AuthFormProps) {
|
||
const [nickname, setNickname] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setLoading(true)
|
||
setError('')
|
||
|
||
try {
|
||
const userId = await authenticateUser(nickname)
|
||
onAuth(userId, nickname)
|
||
} catch (err) {
|
||
setError('Ошибка входа. Попробуйте другой nickname.')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit}>
|
||
<input
|
||
type="text"
|
||
placeholder="Введите ваш nickname"
|
||
value={nickname}
|
||
onChange={(e) => setNickname(e.target.value)}
|
||
minLength={3}
|
||
maxLength={50}
|
||
required
|
||
/>
|
||
<button type="submit" disabled={loading}>
|
||
{loading ? 'Вход...' : 'Войти'}
|
||
</button>
|
||
{error && <div className="error">{error}</div>}
|
||
</form>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 2. ChainList - Список цепочек
|
||
|
||
```typescript
|
||
// ChainList.tsx
|
||
import { useState, useEffect } from 'react'
|
||
|
||
interface ChainListProps {
|
||
userId: string
|
||
onSelectChain: (chain: ChallengeChain) => void
|
||
}
|
||
|
||
export function ChainList({ userId, onSelectChain }: ChainListProps) {
|
||
const [chains, setChains] = useState<ChallengeChain[]>([])
|
||
const [stats, setStats] = useState<UserStats | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [userId])
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
const [chainsData, statsData] = await Promise.all([
|
||
getChains(),
|
||
getUserStats(userId)
|
||
])
|
||
setChains(chainsData)
|
||
setStats(statsData)
|
||
} catch (err) {
|
||
console.error(err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const getChainProgress = (chainId: string) => {
|
||
return stats?.chainStats.find(cs => cs.chainId === chainId)
|
||
}
|
||
|
||
if (loading) return <div>Загрузка...</div>
|
||
|
||
return (
|
||
<div className="chain-list">
|
||
{chains.map(chain => {
|
||
const progress = getChainProgress(chain.id)
|
||
|
||
return (
|
||
<div key={chain.id} className="chain-card" onClick={() => onSelectChain(chain)}>
|
||
<h3>{chain.name}</h3>
|
||
<p>{chain.tasks.length} заданий</p>
|
||
|
||
{progress && (
|
||
<div className="progress">
|
||
<div className="progress-bar" style={{ width: `${progress.progress}%` }} />
|
||
<span>{progress.completedTasks} / {progress.totalTasks}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 3. TaskView - Просмотр и решение задания
|
||
|
||
```typescript
|
||
// TaskView.tsx
|
||
import { useState } from 'react'
|
||
import ReactMarkdown from 'react-markdown'
|
||
|
||
interface TaskViewProps {
|
||
task: ChallengeTask
|
||
userId: string
|
||
onComplete: () => void
|
||
}
|
||
|
||
export function TaskView({ task, userId, onComplete }: TaskViewProps) {
|
||
const [result, setResult] = useState('')
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
const handleSubmit = async () => {
|
||
setSubmitting(true)
|
||
|
||
try {
|
||
const { queueId } = await submitSolution(userId, task.id, result)
|
||
// Переходим к экрану проверки
|
||
// (см. CheckStatusView)
|
||
} catch (err) {
|
||
alert('Ошибка отправки')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="task-view">
|
||
<h2>{task.title}</h2>
|
||
|
||
<div className="task-description">
|
||
<ReactMarkdown>{task.description}</ReactMarkdown>
|
||
</div>
|
||
|
||
<div className="solution-editor">
|
||
<h3>Ваше решение:</h3>
|
||
<textarea
|
||
value={result}
|
||
onChange={(e) => setResult(e.target.value)}
|
||
placeholder="Напишите ваше решение здесь..."
|
||
rows={15}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={!result.trim() || submitting}
|
||
>
|
||
{submitting ? 'Отправка...' : 'Отправить на проверку'}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 4. CheckStatusView - Отслеживание проверки
|
||
|
||
```typescript
|
||
// CheckStatusView.tsx
|
||
import { useState, useEffect } from 'react'
|
||
|
||
interface CheckStatusViewProps {
|
||
queueId: string
|
||
onComplete: (submission: ChallengeSubmission) => void
|
||
}
|
||
|
||
export function CheckStatusView({ queueId, onComplete }: CheckStatusViewProps) {
|
||
const [status, setStatus] = useState<QueueStatus | null>(null)
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
pollCheckStatus(queueId, setStatus)
|
||
.then(onComplete)
|
||
.catch((err) => setError(err.message))
|
||
}, [queueId])
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="check-error">
|
||
<h3>❌ Ошибка проверки</h3>
|
||
<p>{error}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!status) {
|
||
return <div>Инициализация...</div>
|
||
}
|
||
|
||
return (
|
||
<div className="check-status">
|
||
{status.status === 'waiting' && (
|
||
<>
|
||
<div className="spinner" />
|
||
<h3>⏳ Ожидание в очереди</h3>
|
||
{status.position && <p>Позиция в очереди: {status.position}</p>}
|
||
</>
|
||
)}
|
||
|
||
{status.status === 'in_progress' && (
|
||
<>
|
||
<div className="spinner" />
|
||
<h3>🔍 Проверяем ваше решение...</h3>
|
||
<p>Это может занять несколько секунд</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 5. ResultView - Отображение результата
|
||
|
||
```typescript
|
||
// ResultView.tsx
|
||
interface ResultViewProps {
|
||
submission: ChallengeSubmission
|
||
onRetry?: () => void
|
||
onNext?: () => void
|
||
}
|
||
|
||
export function ResultView({ submission, onRetry, onNext }: ResultViewProps) {
|
||
const isAccepted = submission.status === 'accepted'
|
||
|
||
return (
|
||
<div className={`result-view ${isAccepted ? 'accepted' : 'needs-revision'}`}>
|
||
<div className="result-icon">
|
||
{isAccepted ? '✅' : '❌'}
|
||
</div>
|
||
|
||
<h2>{isAccepted ? 'Задание принято!' : 'Требуется доработка'}</h2>
|
||
|
||
<div className="feedback">
|
||
<h3>Комментарий:</h3>
|
||
<p>{submission.feedback}</p>
|
||
</div>
|
||
|
||
<div className="result-meta">
|
||
<p>Попытка #{submission.attemptNumber}</p>
|
||
<p>Время проверки: {getCheckTime(submission)}</p>
|
||
</div>
|
||
|
||
<div className="actions">
|
||
{isAccepted && onNext && (
|
||
<button onClick={onNext} className="btn-primary">
|
||
Следующее задание →
|
||
</button>
|
||
)}
|
||
|
||
{!isAccepted && onRetry && (
|
||
<button onClick={onRetry} className="btn-secondary">
|
||
Попробовать снова
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getCheckTime(submission: ChallengeSubmission): string {
|
||
if (!submission.checkedAt) return 'N/A'
|
||
|
||
const submitted = new Date(submission.submittedAt)
|
||
const checked = new Date(submission.checkedAt)
|
||
const diff = checked.getTime() - submitted.getTime()
|
||
|
||
return `${Math.round(diff / 1000)} сек`
|
||
}
|
||
```
|
||
|
||
### 6. UserStatsView - Статистика пользователя
|
||
|
||
```typescript
|
||
// UserStatsView.tsx
|
||
import { useState, useEffect } from 'react'
|
||
|
||
interface UserStatsViewProps {
|
||
userId: string
|
||
}
|
||
|
||
export function UserStatsView({ userId }: UserStatsViewProps) {
|
||
const [stats, setStats] = useState<UserStats | null>(null)
|
||
|
||
useEffect(() => {
|
||
getUserStats(userId).then(setStats)
|
||
}, [userId])
|
||
|
||
if (!stats) return <div>Загрузка...</div>
|
||
|
||
return (
|
||
<div className="user-stats">
|
||
<h2>Ваша статистика</h2>
|
||
|
||
<div className="stats-overview">
|
||
<div className="stat-card">
|
||
<h3>{stats.completedTasks}</h3>
|
||
<p>Задания выполнено</p>
|
||
</div>
|
||
|
||
<div className="stat-card">
|
||
<h3>{stats.totalTasksAttempted}</h3>
|
||
<p>Всего попыток</p>
|
||
</div>
|
||
|
||
<div className="stat-card">
|
||
<h3>{Math.round(stats.averageCheckTimeMs / 1000)}с</h3>
|
||
<p>Среднее время проверки</p>
|
||
</div>
|
||
</div>
|
||
|
||
<h3>Прогресс по цепочкам</h3>
|
||
{stats.chainStats.map(chain => (
|
||
<div key={chain.chainId} className="chain-progress">
|
||
<h4>{chain.chainName}</h4>
|
||
<div className="progress-bar">
|
||
<div style={{ width: `${chain.progress}%` }} />
|
||
</div>
|
||
<span>{chain.completedTasks} / {chain.totalTasks}</span>
|
||
</div>
|
||
))}
|
||
|
||
<h3>Детали по заданиям</h3>
|
||
{stats.taskStats.map(taskStat => (
|
||
<div key={taskStat.taskId} className="task-stat">
|
||
<h4>{taskStat.taskTitle}</h4>
|
||
<p>Статус: {getStatusLabel(taskStat.status)}</p>
|
||
<p>Попыток: {taskStat.totalAttempts}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getStatusLabel(status: string): string {
|
||
const labels: Record<string, string> = {
|
||
'not_attempted': 'Не начато',
|
||
'pending': 'Ожидает проверки',
|
||
'in_progress': 'Проверяется',
|
||
'completed': 'Завершено',
|
||
'needs_revision': 'Требует доработки'
|
||
}
|
||
return labels[status] || status
|
||
}
|
||
```
|
||
|
||
### 7. AdminDashboard - Панель администратора
|
||
|
||
```typescript
|
||
// AdminDashboard.tsx
|
||
import { useState, useEffect } from 'react'
|
||
|
||
export function AdminDashboard() {
|
||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||
|
||
useEffect(() => {
|
||
const loadStats = async () => {
|
||
const { data } = await apiRequest<SystemStats>('/stats')
|
||
setStats(data)
|
||
}
|
||
|
||
loadStats()
|
||
const interval = setInterval(loadStats, 10000) // Обновляем каждые 10 сек
|
||
|
||
return () => clearInterval(interval)
|
||
}, [])
|
||
|
||
if (!stats) return <div>Загрузка...</div>
|
||
|
||
return (
|
||
<div className="admin-dashboard">
|
||
<h2>Панель администратора</h2>
|
||
|
||
<div className="dashboard-grid">
|
||
<div className="dashboard-card">
|
||
<h3>Пользователи</h3>
|
||
<div className="big-number">{stats.users}</div>
|
||
</div>
|
||
|
||
<div className="dashboard-card">
|
||
<h3>Задания</h3>
|
||
<div className="big-number">{stats.tasks}</div>
|
||
</div>
|
||
|
||
<div className="dashboard-card">
|
||
<h3>Цепочки</h3>
|
||
<div className="big-number">{stats.chains}</div>
|
||
</div>
|
||
|
||
<div className="dashboard-card">
|
||
<h3>Всего проверок</h3>
|
||
<div className="big-number">{stats.submissions.total}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="submissions-chart">
|
||
<h3>Статистика проверок</h3>
|
||
<div className="chart-bars">
|
||
<div className="bar accepted" style={{ height: `${(stats.submissions.accepted / stats.submissions.total) * 100}%` }}>
|
||
<span>Принято: {stats.submissions.accepted}</span>
|
||
</div>
|
||
<div className="bar rejected" style={{ height: `${(stats.submissions.rejected / stats.submissions.total) * 100}%` }}>
|
||
<span>Отклонено: {stats.submissions.rejected}</span>
|
||
</div>
|
||
<div className="bar pending" style={{ height: `${(stats.submissions.pending / stats.submissions.total) * 100}%` }}>
|
||
<span>Ожидают: {stats.submissions.pending}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="queue-status">
|
||
<h3>Статус очереди</h3>
|
||
<div className="queue-info">
|
||
<p>🔄 В обработке: {stats.queue.currentlyProcessing} / {stats.queue.maxConcurrency}</p>
|
||
<p>⏳ В ожидании: {stats.queue.waiting}</p>
|
||
<p>📊 Всего в очереди: {stats.queue.queueLength}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="avg-time">
|
||
<h3>Среднее время проверки</h3>
|
||
<div className="big-number">{Math.round(stats.averageCheckTimeMs / 1000)}с</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## State Management
|
||
|
||
### React Context пример
|
||
|
||
```typescript
|
||
// ChallengeContext.tsx
|
||
import { createContext, useContext, useState, useEffect } from 'react'
|
||
|
||
interface ChallengeContextType {
|
||
userId: string | null
|
||
nickname: string | null
|
||
login: (nickname: string) => Promise<void>
|
||
logout: () => void
|
||
stats: UserStats | null
|
||
refreshStats: () => Promise<void>
|
||
}
|
||
|
||
const ChallengeContext = createContext<ChallengeContextType | undefined>(undefined)
|
||
|
||
export function ChallengeProvider({ children }: { children: React.ReactNode }) {
|
||
const [userId, setUserId] = useState<string | null>(
|
||
localStorage.getItem('challengeUserId')
|
||
)
|
||
const [nickname, setNickname] = useState<string | null>(
|
||
localStorage.getItem('challengeNickname')
|
||
)
|
||
const [stats, setStats] = useState<UserStats | null>(null)
|
||
|
||
const login = async (nickname: string) => {
|
||
const userId = await authenticateUser(nickname)
|
||
setUserId(userId)
|
||
setNickname(nickname)
|
||
localStorage.setItem('challengeNickname', nickname)
|
||
}
|
||
|
||
const logout = () => {
|
||
setUserId(null)
|
||
setNickname(null)
|
||
setStats(null)
|
||
localStorage.removeItem('challengeUserId')
|
||
localStorage.removeItem('challengeNickname')
|
||
}
|
||
|
||
const refreshStats = async () => {
|
||
if (userId) {
|
||
const userStats = await getUserStats(userId)
|
||
setStats(userStats)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (userId) {
|
||
refreshStats()
|
||
}
|
||
}, [userId])
|
||
|
||
return (
|
||
<ChallengeContext.Provider value={{
|
||
userId,
|
||
nickname,
|
||
login,
|
||
logout,
|
||
stats,
|
||
refreshStats
|
||
}}>
|
||
{children}
|
||
</ChallengeContext.Provider>
|
||
)
|
||
}
|
||
|
||
export function useChallenge() {
|
||
const context = useContext(ChallengeContext)
|
||
if (!context) {
|
||
throw new Error('useChallenge must be used within ChallengeProvider')
|
||
}
|
||
return context
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Best Practices
|
||
|
||
### 1. Polling оптимизация
|
||
|
||
```typescript
|
||
// Используйте exponential backoff для polling
|
||
class PollingManager {
|
||
private intervalId: number | null = null
|
||
private currentDelay = 2000 // Начинаем с 2 секунд
|
||
private maxDelay = 10000 // Максимум 10 секунд
|
||
|
||
start(callback: () => Promise<boolean>) {
|
||
const poll = async () => {
|
||
const shouldContinue = await callback()
|
||
|
||
if (shouldContinue) {
|
||
// Увеличиваем delay
|
||
this.currentDelay = Math.min(this.currentDelay * 1.5, this.maxDelay)
|
||
this.intervalId = window.setTimeout(poll, this.currentDelay)
|
||
}
|
||
}
|
||
|
||
poll()
|
||
}
|
||
|
||
stop() {
|
||
if (this.intervalId) {
|
||
clearTimeout(this.intervalId)
|
||
this.intervalId = null
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2. Обработка ошибок
|
||
|
||
```typescript
|
||
// Создайте централизованный обработчик ошибок
|
||
class ChallengeAPIError extends Error {
|
||
constructor(
|
||
public message: string,
|
||
public statusCode?: number,
|
||
public details?: any
|
||
) {
|
||
super(message)
|
||
}
|
||
}
|
||
|
||
async function handleAPIError(response: Response) {
|
||
const data = await response.json()
|
||
|
||
if (data.error) {
|
||
throw new ChallengeAPIError(
|
||
data.error.message || 'Unknown error',
|
||
response.status,
|
||
data.error
|
||
)
|
||
}
|
||
|
||
return data
|
||
}
|
||
```
|
||
|
||
### 3. Кеширование
|
||
|
||
```typescript
|
||
// Кешируйте редко меняющиеся данные
|
||
class ChallengeCache {
|
||
private cache = new Map<string, { data: any; expires: number }>()
|
||
|
||
set(key: string, data: any, ttl: number = 60000) {
|
||
this.cache.set(key, {
|
||
data,
|
||
expires: Date.now() + ttl
|
||
})
|
||
}
|
||
|
||
get(key: string): any | null {
|
||
const cached = this.cache.get(key)
|
||
|
||
if (!cached) return null
|
||
|
||
if (Date.now() > cached.expires) {
|
||
this.cache.delete(key)
|
||
return null
|
||
}
|
||
|
||
return cached.data
|
||
}
|
||
}
|
||
|
||
const cache = new ChallengeCache()
|
||
|
||
async function getChainsWithCache(): Promise<ChallengeChain[]> {
|
||
const cached = cache.get('chains')
|
||
if (cached) return cached
|
||
|
||
const chains = await getChains()
|
||
cache.set('chains', chains, 5 * 60 * 1000) // 5 минут
|
||
|
||
return chains
|
||
}
|
||
```
|
||
|
||
### 4. Оптимистичные обновления
|
||
|
||
```typescript
|
||
// Показывайте изменения сразу, до ответа от сервера
|
||
async function optimisticSubmit(
|
||
task: ChallengeTask,
|
||
result: string,
|
||
updateUI: (status: 'submitting' | 'waiting' | 'checking') => void
|
||
) {
|
||
updateUI('submitting')
|
||
|
||
try {
|
||
const { queueId } = await submitSolution(userId, task.id, result)
|
||
updateUI('waiting')
|
||
|
||
const submission = await pollCheckStatus(queueId, (status) => {
|
||
if (status.status === 'in_progress') {
|
||
updateUI('checking')
|
||
}
|
||
})
|
||
|
||
return submission
|
||
} catch (err) {
|
||
// Откатываем UI если ошибка
|
||
throw err
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5. Accessibility
|
||
|
||
```typescript
|
||
// Добавляйте ARIA атрибуты
|
||
<div
|
||
role="alert"
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
>
|
||
{status === 'waiting' && 'Ожидание в очереди...'}
|
||
{status === 'in_progress' && 'Проверка решения...'}
|
||
</div>
|
||
|
||
// Используйте semantic HTML
|
||
<button
|
||
aria-label="Отправить решение на проверку"
|
||
disabled={submitting}
|
||
aria-busy={submitting}
|
||
>
|
||
Отправить
|
||
</button>
|
||
```
|
||
|
||
### 6. Offline support
|
||
|
||
```typescript
|
||
// Сохраняйте незавершенные решения локально
|
||
function saveDraft(taskId: string, result: string) {
|
||
localStorage.setItem(`draft_${taskId}`, result)
|
||
}
|
||
|
||
function loadDraft(taskId: string): string | null {
|
||
return localStorage.getItem(`draft_${taskId}`)
|
||
}
|
||
|
||
function clearDraft(taskId: string) {
|
||
localStorage.removeItem(`draft_${taskId}`)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Примеры интеграции
|
||
|
||
### React + TypeScript
|
||
|
||
Полный пример приложения доступен в репозитории: `/examples/react-challenge-app`
|
||
|
||
### Vue.js
|
||
|
||
```typescript
|
||
// composition API
|
||
import { ref, onMounted } from 'vue'
|
||
|
||
export function useChallengeSubmission(taskId: string) {
|
||
const result = ref('')
|
||
const submitting = ref(false)
|
||
const checkStatus = ref<QueueStatus | null>(null)
|
||
|
||
const submit = async () => {
|
||
submitting.value = true
|
||
|
||
try {
|
||
const { queueId } = await submitSolution(userId, taskId, result.value)
|
||
|
||
await pollCheckStatus(queueId, (status) => {
|
||
checkStatus.value = status
|
||
})
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
return {
|
||
result,
|
||
submitting,
|
||
checkStatus,
|
||
submit
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Заключение
|
||
|
||
Этот сервис предоставляет гибкий API для создания различных пользовательских интерфейсов. Основные принципы:
|
||
|
||
1. **Polling для отслеживания** - используйте разумные интервалы
|
||
2. **Оптимистичные обновления** - для лучшего UX
|
||
3. **Кеширование** - для производительности
|
||
4. **Обработка ошибок** - для надежности
|
||
5. **Accessibility** - для всех пользователей
|
||
|
||
Используйте предоставленные компоненты как основу и адаптируйте под ваш дизайн-систему!
|
||
|