challenge-admin-pl/docs/CHALLENGE_REACT_EXAMPLE.md
Primakov Alexandr Alexandrovich e777b57991 init + api use
2025-11-03 17:59:08 +03:00

24 KiB
Raw Blame History

Challenge Service - React Example

Полный пример интеграции сервиса в React приложение с TypeScript.

Структура проекта

src/
├── api/
│   └── challenge.ts          # API клиент
├── components/
│   ├── AuthForm.tsx
│   ├── ChainList.tsx
│   ├── TaskView.tsx
│   ├── CheckStatus.tsx
│   ├── ResultView.tsx
│   └── UserStats.tsx
├── context/
│   └── ChallengeContext.tsx  # State management
├── hooks/
│   ├── useChallenge.ts
│   ├── usePolling.ts
│   └── useSubmission.ts
├── types/
│   └── challenge.ts          # TypeScript типы
└── App.tsx

Полный код

1. TypeScript Types (src/types/challenge.ts)

export interface ChallengeUser {
  _id: string
  id: string
  nickname: string
  createdAt: string
}

export interface ChallengeTask {
  _id: string
  id: string
  title: string
  description: string
  createdAt: string
  updatedAt: string
}

export interface ChallengeChain {
  _id: string
  id: string
  name: string
  tasks: ChallengeTask[]
  createdAt: string
  updatedAt: string
}

export type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision'

export interface ChallengeSubmission {
  _id: string
  id: string
  user: string
  task: string
  result: string
  status: SubmissionStatus
  queueId?: string
  feedback?: string
  submittedAt: string
  checkedAt?: string
  attemptNumber: number
}

export type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found'

export interface QueueStatus {
  status: QueueStatusType
  submission?: ChallengeSubmission & { task: ChallengeTask }
  error?: string
  position?: number
}

export interface UserStats {
  totalTasksAttempted: number
  completedTasks: number
  inProgressTasks: number
  needsRevisionTasks: number
  totalSubmissions: number
  averageCheckTimeMs: number
  taskStats: Array<{
    taskId: string
    taskTitle: string
    totalAttempts: number
    status: string
    lastAttemptAt: string | null
  }>
  chainStats: Array<{
    chainId: string
    chainName: string
    totalTasks: number
    completedTasks: number
    progress: number
  }>
}

2. API Client (src/api/challenge.ts)

import type {
  ChallengeChain,
  ChallengeSubmission,
  QueueStatus,
  UserStats,
} from '../types/challenge'

const API_BASE = 'http://localhost:8082/api/challenge'

interface APIResponse<T> {
  error: any
  data: T
}

async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
  const response = await fetch(`${API_BASE}${endpoint}`, {
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers,
    },
    ...options,
  })

  const json: APIResponse<T> = await response.json()

  if (json.error) {
    throw new Error(json.error.message || 'API Error')
  }

  return json.data
}

export const challengeAPI = {
  // Аутентификация
  async auth(nickname: string) {
    return request<{ ok: boolean; userId: string }>('/auth', {
      method: 'POST',
      body: JSON.stringify({ nickname }),
    })
  },

  // Цепочки
  async getChains() {
    return request<ChallengeChain[]>('/chains')
  },

  async getChain(chainId: string) {
    return request<ChallengeChain>(`/chain/${chainId}`)
  },

  // Отправка решения
  async submit(userId: string, taskId: string, result: string) {
    return request<{ queueId: string; submissionId: string }>('/submit', {
      method: 'POST',
      body: JSON.stringify({ userId, taskId, result }),
    })
  },

  // Проверка статуса
  async checkStatus(queueId: string) {
    return request<QueueStatus>(`/check-status/${queueId}`)
  },

  // Статистика
  async getUserStats(userId: string) {
    return request<UserStats>(`/user/${userId}/stats`)
  },

  // Попытки
  async getSubmissions(userId: string, taskId?: string) {
    const query = taskId ? `?taskId=${taskId}` : ''
    return request<ChallengeSubmission[]>(`/user/${userId}/submissions${query}`)
  },
}

3. Context (src/context/ChallengeContext.tsx)

import React, { createContext, useContext, useState, useEffect } from 'react'
import { challengeAPI } from '../api/challenge'
import type { UserStats } from '../types/challenge'

interface ChallengeContextType {
  userId: string | null
  nickname: string | null
  stats: UserStats | null
  isAuthenticated: boolean
  login: (nickname: string) => Promise<void>
  logout: () => void
  refreshStats: () => Promise<void>
}

const ChallengeContext = createContext<ChallengeContextType | undefined>(undefined)

export function ChallengeProvider({ children }: { children: React.ReactNode }) {
  const [userId, setUserId] = useState<string | null>(
    localStorage.getItem('challenge_user_id')
  )
  const [nickname, setNickname] = useState<string | null>(
    localStorage.getItem('challenge_nickname')
  )
  const [stats, setStats] = useState<UserStats | null>(null)

  const login = async (nickname: string) => {
    const { userId } = await challengeAPI.auth(nickname)
    setUserId(userId)
    setNickname(nickname)
    localStorage.setItem('challenge_user_id', userId)
    localStorage.setItem('challenge_nickname', nickname)
  }

  const logout = () => {
    setUserId(null)
    setNickname(null)
    setStats(null)
    localStorage.removeItem('challenge_user_id')
    localStorage.removeItem('challenge_nickname')
  }

  const refreshStats = async () => {
    if (userId) {
      const userStats = await challengeAPI.getUserStats(userId)
      setStats(userStats)
    }
  }

  useEffect(() => {
    if (userId) {
      refreshStats()
    }
  }, [userId])

  return (
    <ChallengeContext.Provider
      value={{
        userId,
        nickname,
        stats,
        isAuthenticated: !!userId,
        login,
        logout,
        refreshStats,
      }}
    >
      {children}
    </ChallengeContext.Provider>
  )
}

export function useChallenge() {
  const context = useContext(ChallengeContext)
  if (!context) {
    throw new Error('useChallenge must be used within ChallengeProvider')
  }
  return context
}

4. Custom Hooks

src/hooks/usePolling.ts

import { useEffect, useRef } from 'react'

export function usePolling(
  callback: () => Promise<boolean>,
  interval: number = 2000,
  enabled: boolean = true
) {
  const timeoutRef = useRef<NodeJS.Timeout>()

  useEffect(() => {
    if (!enabled) return

    const poll = async () => {
      try {
        const shouldContinue = await callback()
        if (shouldContinue) {
          timeoutRef.current = setTimeout(poll, interval)
        }
      } catch (error) {
        console.error('Polling error:', error)
      }
    }

    poll()

    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }
    }
  }, [callback, interval, enabled])
}

src/hooks/useSubmission.ts

import { useState } from 'react'
import { challengeAPI } from '../api/challenge'
import type { ChallengeSubmission, QueueStatus } from '../types/challenge'

export function useSubmission(userId: string, taskId: string) {
  const [result, setResult] = useState('')
  const [submitting, setSubmitting] = useState(false)
  const [queueId, setQueueId] = useState<string | null>(null)
  const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null)
  const [finalSubmission, setFinalSubmission] = useState<ChallengeSubmission | null>(null)

  const submit = async () => {
    if (!result.trim()) return

    setSubmitting(true)

    try {
      const { queueId: newQueueId } = await challengeAPI.submit(userId, taskId, result)
      setQueueId(newQueueId)
    } catch (error) {
      console.error('Submit error:', error)
      alert('Ошибка отправки решения')
    } finally {
      setSubmitting(false)
    }
  }

  const checkStatus = async () => {
    if (!queueId) return false

    const status = await challengeAPI.checkStatus(queueId)
    setQueueStatus(status)

    if (status.status === 'completed' && status.submission) {
      setFinalSubmission(status.submission as any)
      return false // Останавливаем polling
    }

    if (status.status === 'error') {
      return false // Останавливаем polling
    }

    return true // Продолжаем polling
  }

  const reset = () => {
    setResult('')
    setQueueId(null)
    setQueueStatus(null)
    setFinalSubmission(null)
  }

  return {
    result,
    setResult,
    submitting,
    queueId,
    queueStatus,
    finalSubmission,
    submit,
    checkStatus,
    reset,
  }
}

5. Components

src/components/AuthForm.tsx

import React, { useState } from 'react'
import { useChallenge } from '../context/ChallengeContext'

export function AuthForm() {
  const [nickname, setNickname] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')
  const { login } = useChallenge()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setError('')

    try {
      await login(nickname)
    } catch (err) {
      setError('Ошибка входа. Попробуйте другой nickname.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="auth-form">
      <h2>Вход в систему</h2>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="Введите ваш nickname"
          value={nickname}
          onChange={(e) => setNickname(e.target.value)}
          minLength={3}
          maxLength={50}
          required
          disabled={loading}
        />
        <button type="submit" disabled={loading || !nickname.trim()}>
          {loading ? 'Вход...' : 'Войти'}
        </button>
        {error && <div className="error">{error}</div>}
      </form>
    </div>
  )
}

src/components/ChainList.tsx

import React, { useState, useEffect } from 'react'
import { challengeAPI } from '../api/challenge'
import { useChallenge } from '../context/ChallengeContext'
import type { ChallengeChain } from '../types/challenge'

interface Props {
  onSelectChain: (chain: ChallengeChain) => void
}

export function ChainList({ onSelectChain }: Props) {
  const [chains, setChains] = useState<ChallengeChain[]>([])
  const [loading, setLoading] = useState(true)
  const { stats } = useChallenge()

  useEffect(() => {
    challengeAPI
      .getChains()
      .then(setChains)
      .finally(() => setLoading(false))
  }, [])

  if (loading) return <div>Загрузка цепочек...</div>

  return (
    <div className="chain-list">
      <h2>Доступные цепочки заданий</h2>
      {chains.map((chain) => {
        const chainProgress = stats?.chainStats.find((cs) => cs.chainId === chain.id)

        return (
          <div
            key={chain.id}
            className="chain-card"
            onClick={() => onSelectChain(chain)}
          >
            <h3>{chain.name}</h3>
            <p>{chain.tasks.length} заданий</p>

            {chainProgress && (
              <div className="progress-container">
                <div
                  className="progress-bar"
                  style={{ width: `${chainProgress.progress}%` }}
                />
                <span className="progress-text">
                  {chainProgress.completedTasks} / {chainProgress.totalTasks} выполнено
                </span>
              </div>
            )}
          </div>
        )
      })}
    </div>
  )
}

src/components/TaskView.tsx

import React from 'react'
import ReactMarkdown from 'react-markdown'
import { useSubmission } from '../hooks/useSubmission'
import { usePolling } from '../hooks/usePolling'
import { useChallenge } from '../context/ChallengeContext'
import { CheckStatus } from './CheckStatus'
import { ResultView } from './ResultView'
import type { ChallengeTask } from '../types/challenge'

interface Props {
  task: ChallengeTask
  onComplete: () => void
}

export function TaskView({ task, onComplete }: Props) {
  const { userId } = useChallenge()
  const {
    result,
    setResult,
    submitting,
    queueId,
    queueStatus,
    finalSubmission,
    submit,
    checkStatus,
    reset,
  } = useSubmission(userId!, task.id)

  // Запускаем polling когда есть queueId
  usePolling(checkStatus, 2000, !!queueId && !finalSubmission)

  if (finalSubmission) {
    return (
      <ResultView
        submission={finalSubmission}
        onRetry={reset}
        onNext={onComplete}
      />
    )
  }

  if (queueId) {
    return <CheckStatus status={queueStatus} />
  }

  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}
          disabled={submitting}
        />
      </div>

      <button onClick={submit} disabled={!result.trim() || submitting}>
        {submitting ? 'Отправка...' : 'Отправить на проверку'}
      </button>
    </div>
  )
}

src/components/CheckStatus.tsx

import React from 'react'
import type { QueueStatus } from '../types/challenge'

interface Props {
  status: QueueStatus | null
}

export function CheckStatus({ status }: Props) {
  if (!status) {
    return <div className="check-status">Инициализация...</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>
        </>
      )}

      {status.status === 'error' && (
        <>
          <h3> Ошибка проверки</h3>
          <p>{status.error || 'Неизвестная ошибка'}</p>
        </>
      )}
    </div>
  )
}

src/components/ResultView.tsx

import React from 'react'
import type { ChallengeSubmission } from '../types/challenge'

interface Props {
  submission: ChallengeSubmission
  onRetry?: () => void
  onNext?: () => void
}

export function ResultView({ submission, onRetry, onNext }: Props) {
  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>
        {submission.checkedAt && (
          <p>
            Время проверки:{' '}
            {Math.round(
              (new Date(submission.checkedAt).getTime() -
                new Date(submission.submittedAt).getTime()) /
                1000
            )}{' '}
            сек
          </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>
  )
}

6. Main App (src/App.tsx)

import React, { useState } from 'react'
import { ChallengeProvider, useChallenge } from './context/ChallengeContext'
import { AuthForm } from './components/AuthForm'
import { ChainList } from './components/ChainList'
import { TaskView } from './components/TaskView'
import type { ChallengeChain, ChallengeTask } from './types/challenge'
import './App.css'

function AppContent() {
  const { isAuthenticated, nickname, logout, refreshStats } = useChallenge()
  const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
  const [currentTaskIndex, setCurrentTaskIndex] = useState(0)

  if (!isAuthenticated) {
    return <AuthForm />
  }

  const handleTaskComplete = () => {
    refreshStats()
    
    if (selectedChain && currentTaskIndex < selectedChain.tasks.length - 1) {
      setCurrentTaskIndex(currentTaskIndex + 1)
    } else {
      // Цепочка завершена
      setSelectedChain(null)
      setCurrentTaskIndex(0)
    }
  }

  const handleBackToChains = () => {
    setSelectedChain(null)
    setCurrentTaskIndex(0)
  }

  if (selectedChain) {
    const currentTask = selectedChain.tasks[currentTaskIndex]

    return (
      <div className="app-container">
        <header>
          <h1>Challenge Platform</h1>
          <div className="user-info">
            <span>👤 {nickname}</span>
            <button onClick={logout}>Выйти</button>
          </div>
        </header>

        <div className="chain-progress">
          <button onClick={handleBackToChains}> Назад к цепочкам</button>
          <h3>{selectedChain.name}</h3>
          <p>
            Задание {currentTaskIndex + 1} из {selectedChain.tasks.length}
          </p>
        </div>

        <main>
          <TaskView task={currentTask} onComplete={handleTaskComplete} />
        </main>
      </div>
    )
  }

  return (
    <div className="app-container">
      <header>
        <h1>Challenge Platform</h1>
        <div className="user-info">
          <span>👤 {nickname}</span>
          <button onClick={logout}>Выйти</button>
        </div>
      </header>

      <main>
        <ChainList onSelectChain={setSelectedChain} />
      </main>
    </div>
  )
}

function App() {
  return (
    <ChallengeProvider>
      <AppContent />
    </ChallengeProvider>
  )
}

export default App

7. Styles (src/App.css)

.app-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px 0;
  border-bottom: 2px solid #eee;
  margin-bottom: 40px;
}

.user-info {
  display: flex;
  gap: 15px;
  align-items: center;
}

/* Auth Form */
.auth-form {
  max-width: 400px;
  margin: 100px auto;
  padding: 40px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

.auth-form input {
  width: 100%;
  padding: 12px;
  margin: 10px 0;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

.auth-form button {
  width: 100%;
  padding: 12px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
}

.auth-form button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.error {
  color: #dc3545;
  margin-top: 10px;
}

/* Chain List */
.chain-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
}

.chain-card {
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
}

.chain-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.progress-container {
  margin-top: 15px;
}

.progress-bar {
  height: 8px;
  background: #007bff;
  border-radius: 4px;
  transition: width 0.3s;
}

.progress-text {
  display: block;
  margin-top: 5px;
  font-size: 14px;
  color: #666;
}

/* Task View */
.task-view {
  max-width: 800px;
  margin: 0 auto;
}

.task-description {
  padding: 20px;
  background: #f8f9fa;
  border-radius: 8px;
  margin: 20px 0;
}

.solution-editor textarea {
  width: 100%;
  padding: 15px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-family: 'Monaco', 'Courier New', monospace;
  font-size: 14px;
}

/* Check Status */
.check-status {
  text-align: center;
  padding: 60px 20px;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 5px solid #f3f3f3;
  border-top: 5px solid #007bff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 20px;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

/* Result View */
.result-view {
  max-width: 600px;
  margin: 40px auto;
  padding: 40px;
  border-radius: 8px;
  text-align: center;
}

.result-view.accepted {
  background: #d4edda;
  border: 2px solid #28a745;
}

.result-view.needs-revision {
  background: #f8d7da;
  border: 2px solid #dc3545;
}

.result-icon {
  font-size: 64px;
  margin-bottom: 20px;
}

.feedback {
  margin: 30px 0;
  padding: 20px;
  background: white;
  border-radius: 4px;
  text-align: left;
}

.result-meta {
  color: #666;
  font-size: 14px;
}

.actions {
  margin-top: 30px;
  display: flex;
  gap: 10px;
  justify-content: center;
}

button {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
}

.btn-primary {
  background: #007bff;
  color: white;
}

.btn-secondary {
  background: #6c757d;
  color: white;
}

button:hover {
  opacity: 0.9;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

Запуск проекта

# Установка зависимостей
npm install react react-dom
npm install -D @types/react @types/react-dom typescript
npm install react-markdown

# Запуск dev сервера
npm run dev

package.json

{
  "name": "challenge-react-app",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-markdown": "^9.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@vitejs/plugin-react": "^4.0.0",
    "typescript": "^5.0.0",
    "vite": "^5.0.0"
  }
}

Особенности реализации

1. Автоматический polling

  • Использует custom hook usePolling
  • Запускается только когда есть queueId
  • Останавливается при получении финального результата

2. State management

  • Context API для глобального состояния
  • Local state для компонентов
  • LocalStorage для персистентности

3. Error handling

  • Try-catch блоки для всех API вызовов
  • Отображение ошибок пользователю
  • Graceful degradation

4. UX оптимизации

  • Loading состояния
  • Disabled кнопки во время запросов
  • Прогресс бары для цепочек
  • Индикаторы позиции в очереди

5. TypeScript

  • Полная типизация
  • Autocompletion в IDE
  • Меньше runtime ошибок

Расширения

Добавьте эти фичи для улучшения UX:

  1. Автосохранение черновиков
  2. Таймер на задание
  3. История попыток с детализацией
  4. Markdown preview в редакторе
  5. Dark mode
  6. Горячие клавиши
  7. Экспорт статистики

Используйте этот пример как основу для вашего приложения!