24 KiB
24 KiB
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:
- Автосохранение черновиков
- Таймер на задание
- История попыток с детализацией
- Markdown preview в редакторе
- Dark mode
- Горячие клавиши
- Экспорт статистики
Используйте этот пример как основу для вашего приложения!