276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
'use client';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import { apiFetch } from '../lib/api';
|
||
|
||
interface CodingChallengeState {
|
||
id: number;
|
||
order: number;
|
||
title: string;
|
||
prompt: string;
|
||
starter_code: string | null;
|
||
is_passed: boolean;
|
||
is_unlocked: boolean;
|
||
last_submitted_code: string | null;
|
||
last_stdout: string | null;
|
||
last_stderr: string | null;
|
||
last_exit_code: number | null;
|
||
updated_at: string | null;
|
||
}
|
||
|
||
interface CodingMissionState {
|
||
mission_id: number;
|
||
total_challenges: number;
|
||
completed_challenges: number;
|
||
current_challenge_id: number | null;
|
||
is_mission_completed: boolean;
|
||
challenges: CodingChallengeState[];
|
||
}
|
||
|
||
interface CodingMissionPanelProps {
|
||
missionId: number;
|
||
token?: string;
|
||
initialState: CodingMissionState | null;
|
||
initialCompleted?: boolean;
|
||
}
|
||
|
||
interface RunResult {
|
||
stdout: string;
|
||
stderr: string;
|
||
exit_code: number;
|
||
is_passed: boolean;
|
||
mission_completed: boolean;
|
||
expected_output?: string | null;
|
||
}
|
||
|
||
export function CodingMissionPanel({ missionId, token, initialState, initialCompleted = false }: CodingMissionPanelProps) {
|
||
const [state, setState] = useState<CodingMissionState | null>(initialState);
|
||
const [missionCompleted, setMissionCompleted] = useState(initialState?.is_mission_completed || initialCompleted);
|
||
const [editorCode, setEditorCode] = useState<string>('');
|
||
const [status, setStatus] = useState<string | null>(null);
|
||
const [runResult, setRunResult] = useState<RunResult | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const previousChallengeIdRef = useRef<number | null>(null);
|
||
|
||
|
||
const activeChallenge = useMemo(() => {
|
||
if (!state || !state.challenges.length) {
|
||
return null;
|
||
}
|
||
if (state.current_challenge_id) {
|
||
const current = state.challenges.find((challenge) => challenge.id === state.current_challenge_id);
|
||
if (current) {
|
||
return current;
|
||
}
|
||
}
|
||
|
||
const nextUnlocked = state.challenges.find((challenge) => challenge.is_unlocked && !challenge.is_passed);
|
||
if (nextUnlocked) {
|
||
return nextUnlocked;
|
||
}
|
||
|
||
const firstIncomplete = state.challenges.find((challenge) => !challenge.is_passed);
|
||
if (firstIncomplete) {
|
||
return firstIncomplete;
|
||
}
|
||
|
||
return state.challenges[state.challenges.length - 1];
|
||
}, [state]);
|
||
|
||
useEffect(() => {
|
||
if (!state) {
|
||
return;
|
||
}
|
||
setMissionCompleted(state.is_mission_completed);
|
||
}, [state]);
|
||
|
||
useEffect(() => {
|
||
if (!activeChallenge) {
|
||
previousChallengeIdRef.current = null;
|
||
return;
|
||
}
|
||
|
||
if (previousChallengeIdRef.current === activeChallenge.id) {
|
||
return;
|
||
}
|
||
|
||
previousChallengeIdRef.current = activeChallenge.id;
|
||
const baseCode = activeChallenge.last_submitted_code ?? activeChallenge.starter_code ?? '';
|
||
setEditorCode(baseCode);
|
||
setRunResult(null);
|
||
setStatus(null);
|
||
}, [activeChallenge]);
|
||
|
||
if (!state) {
|
||
return (
|
||
<div className="card" style={{ marginTop: '2rem' }}>
|
||
<p>
|
||
Не удалось загрузить задания для миссии. Попробуйте обновить страницу или обратитесь к HR, чтобы
|
||
проверить настройки миссии.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const handleRefresh = async () => {
|
||
if (!token) {
|
||
return;
|
||
}
|
||
try {
|
||
const updated = await apiFetch<CodingMissionState>(`/api/missions/${missionId}/coding/challenges`, {
|
||
authToken: token
|
||
});
|
||
setState(updated);
|
||
} catch (error) {
|
||
if (error instanceof Error) {
|
||
setStatus(error.message);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleRun = async () => {
|
||
if (!token) {
|
||
setStatus('Не удалось получить токен авторизации. Перезайдите в систему.');
|
||
return;
|
||
}
|
||
if (!activeChallenge) {
|
||
setStatus('Все задания выполнены — можно переходить к другим миссиям.');
|
||
return;
|
||
}
|
||
if (!editorCode.trim()) {
|
||
setStatus('Добавьте решение в редакторе, прежде чем запускать проверку.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLoading(true);
|
||
setStatus(null);
|
||
const result = await apiFetch<RunResult>(
|
||
`/api/missions/${missionId}/coding/challenges/${activeChallenge.id}/run`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({ code: editorCode }),
|
||
authToken: token
|
||
}
|
||
);
|
||
setRunResult(result);
|
||
setMissionCompleted(result.mission_completed);
|
||
setStatus(
|
||
result.is_passed
|
||
? 'Отлично! Задание пройдено. Можно переходить к следующему шагу.'
|
||
: 'Проверка не пройдена. Сверьтесь с выводом программы и подсказками.'
|
||
);
|
||
await handleRefresh();
|
||
} catch (error) {
|
||
if (error instanceof Error) {
|
||
setStatus(error.message);
|
||
} else {
|
||
setStatus('Неожиданная ошибка при выполнении проверки. Попробуйте повторить позже.');
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||
{state.challenges.map((challenge) => {
|
||
const isActive = activeChallenge?.id === challenge.id && !missionCompleted;
|
||
return (
|
||
<div key={challenge.id} className="card" style={{ border: challenge.is_passed ? '1px solid rgba(85,239,196,0.4)' : undefined }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<h3 style={{ marginBottom: '0.25rem' }}>
|
||
{challenge.order}. {challenge.title}
|
||
</h3>
|
||
{challenge.is_passed && <span style={{ color: '#55efc4' }}>✓ Готово</span>}
|
||
</div>
|
||
<p style={{ color: 'var(--text-muted)' }}>{challenge.prompt}</p>
|
||
{!challenge.is_unlocked && !challenge.is_passed && (
|
||
<p style={{ marginTop: '0.75rem', color: 'var(--text-muted)' }}>
|
||
🔒 Задание откроется после успешного решения предыдущих пунктов.
|
||
</p>
|
||
)}
|
||
{challenge.is_passed && challenge.last_stdout && (
|
||
<div style={{ marginTop: '0.75rem' }}>
|
||
<small style={{ color: 'var(--text-muted)' }}>Последний вывод программы:</small>
|
||
<pre style={{ background: 'rgba(99, 110, 114, 0.2)', padding: '0.75rem', borderRadius: '12px', overflowX: 'auto' }}>
|
||
{challenge.last_stdout}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{isActive && (
|
||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||
<label>
|
||
<span style={{ display: 'block', marginBottom: '0.5rem' }}>Редактор кода</span>
|
||
<textarea
|
||
value={editorCode}
|
||
onChange={(event) => setEditorCode(event.target.value)}
|
||
rows={12}
|
||
style={{ width: '100%', borderRadius: '12px', padding: '0.75rem', fontFamily: 'monospace' }}
|
||
disabled={loading || missionCompleted}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="primary"
|
||
onClick={handleRun}
|
||
disabled={loading || missionCompleted}
|
||
>
|
||
{missionCompleted ? 'Миссия завершена' : loading ? 'Выполняем код...' : 'Проверить'}
|
||
</button>
|
||
{status && (
|
||
<p
|
||
style={{
|
||
marginTop: '-0.25rem',
|
||
color: status.startsWith('Отлично') ? 'var(--accent-light)' : status.includes('Проверка') ? 'var(--error)' : 'var(--text-muted)'
|
||
}}
|
||
>
|
||
{status}
|
||
</p>
|
||
)}
|
||
{runResult && (
|
||
<div>
|
||
<details open>
|
||
<summary style={{ cursor: 'pointer', color: 'var(--accent)' }}>Результат последнего запуска</summary>
|
||
<div style={{ marginTop: '0.5rem' }}>
|
||
<strong>stdout:</strong>
|
||
<pre style={{ background: 'rgba(108, 92, 231, 0.15)', padding: '0.75rem', borderRadius: '12px', overflowX: 'auto' }}>
|
||
{runResult.stdout || '<пусто>'}
|
||
</pre>
|
||
<strong>stderr:</strong>
|
||
<pre style={{ background: 'rgba(225, 112, 85, 0.15)', padding: '0.75rem', borderRadius: '12px', overflowX: 'auto' }}>
|
||
{runResult.stderr || '<пусто>'}
|
||
</pre>
|
||
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
|
||
Код завершился с статусом {runResult.exit_code}.
|
||
</p>
|
||
{runResult.expected_output && (
|
||
<div style={{ marginTop: '0.5rem' }}>
|
||
<strong>Ожидаемый вывод:</strong>
|
||
<pre style={{ background: 'rgba(99, 110, 114, 0.2)', padding: '0.75rem', borderRadius: '12px', overflowX: 'auto' }}>
|
||
{runResult.expected_output}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{missionCompleted && (
|
||
<div className="card" style={{ border: '1px solid rgba(85,239,196,0.35)', background: 'rgba(85,239,196,0.12)' }}>
|
||
<h3>Все задания выполнены!</h3>
|
||
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
|
||
Система уже зачислила опыт и ману за миссию. Можно вернуться к списку миссий и выбрать следующее испытание.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|