Files
pyisu/frontend/src/components/TestViewer.js
2026-03-13 14:39:43 +08:00

369 lines
14 KiB
JavaScript

import { coursesAPI } from '../api/courses.js';
export class TestViewer {
constructor(container, stepId) {
this.container = container;
this.stepId = stepId;
this.questions = [];
this.settings = {};
this.attempts = [];
this.currentQuestionIndex = 0;
this.answers = {};
this.timeRemaining = 0;
this.timerInterval = null;
this.isSubmitted = false;
}
async load() {
try {
const response = await coursesAPI.getTestForViewing(this.stepId);
if (response) {
this.questions = response.questions || [];
this.settings = response.settings || {};
this.attempts = response.attempts || [];
// Initialize answers object
this.questions.forEach(q => {
if (q.question_type === 'single_choice') {
this.answers[q.id] = null;
} else if (q.question_type === 'multiple_choice') {
this.answers[q.id] = [];
} else if (q.question_type === 'text_answer') {
this.answers[q.id] = '';
} else if (q.question_type === 'match') {
this.answers[q.id] = {};
}
});
// Start timer if time limit is set
if (this.settings.time_limit > 0) {
this.timeRemaining = this.settings.time_limit * 60;
this.startTimer();
}
this.render();
}
} catch (error) {
console.error('Failed to load test:', error);
this.showError('Ошибка загрузки теста: ' + error.message);
}
}
startTimer() {
this.updateTimerDisplay();
this.timerInterval = setInterval(() => {
this.timeRemaining--;
this.updateTimerDisplay();
if (this.timeRemaining <= 0) {
clearInterval(this.timerInterval);
this.submitTest();
}
}, 1000);
}
updateTimerDisplay() {
const timerEl = document.getElementById('test-timer');
if (timerEl) {
const minutes = Math.floor(this.timeRemaining / 60);
const seconds = this.timeRemaining % 60;
timerEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
if (this.timeRemaining < 300) { // Less than 5 minutes
timerEl.classList.add('warning');
}
}
}
render() {
if (this.isSubmitted) {
return this.renderResults();
}
const question = this.questions[this.currentQuestionIndex];
this.container.innerHTML = `
<div class="test-viewer">
<div class="test-header">
<div class="test-info">
<span class="question-counter">Вопрос ${this.currentQuestionIndex + 1} из ${this.questions.length}</span>
${this.settings.time_limit > 0 ? `
<span class="test-timer" id="test-timer">
${Math.floor(this.timeRemaining / 60)}:${(this.timeRemaining % 60).toString().padStart(2, '0')}
</span>
` : ''}
</div>
<div class="test-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${((this.currentQuestionIndex + 1) / this.questions.length * 100)}%"></div>
</div>
</div>
</div>
<div class="question-container">
${this.renderQuestion(question)}
</div>
<div class="test-navigation">
<button class="btn btn-outline" onclick="window.prevQuestion()" ${this.currentQuestionIndex === 0 ? 'disabled' : ''}>
← Назад
</button>
<div class="question-dots">
${this.questions.map((q, idx) => `
<button class="dot ${idx === this.currentQuestionIndex ? 'active' : ''} ${this.answers[q.id] ? 'answered' : ''}"
onclick="window.goToQuestion(${idx})">
${idx + 1}
</button>
`).join('')}
</div>
${this.currentQuestionIndex < this.questions.length - 1 ? `
<button class="btn btn-primary" onclick="window.nextQuestion()">
Далее →
</button>
` : `
<button class="btn btn-success" onclick="window.submitTest()">
Завершить тест
</button>
`}
</div>
</div>
`;
}
renderQuestion(question) {
if (!question) return '<p>Вопрос не найден</p>';
return `
<div class="question" data-question-id="${question.id}">
<div class="question-header">
<span class="question-points">${question.points} балл(ов)</span>
</div>
<div class="question-text">${question.question_text}</div>
<div class="question-answers">
${this.renderAnswers(question)}
</div>
</div>
`;
}
renderAnswers(question) {
switch (question.question_type) {
case 'single_choice':
return this.renderSingleChoice(question);
case 'multiple_choice':
return this.renderMultipleChoice(question);
case 'text_answer':
return this.renderTextAnswer(question);
case 'match':
return this.renderMatch(question);
default:
return '<p>Неизвестный тип вопроса</p>';
}
}
renderSingleChoice(question) {
return `
<div class="answers-list single-choice">
${question.answers.map(answer => `
<label class="answer-option ${this.answers[question.id] === answer.id ? 'selected' : ''}">
<input type="radio" name="question-${question.id}" value="${answer.id}"
${this.answers[question.id] === answer.id ? 'checked' : ''}
onchange="window.selectAnswer(${question.id}, ${answer.id})">
<span class="answer-text">${answer.answer_text}</span>
</label>
`).join('')}
</div>
`;
}
renderMultipleChoice(question) {
return `
<div class="answers-list multiple-choice">
${question.answers.map(answer => `
<label class="answer-option ${this.answers[question.id]?.includes(answer.id) ? 'selected' : ''}">
<input type="checkbox" value="${answer.id}"
${this.answers[question.id]?.includes(answer.id) ? 'checked' : ''}
onchange="window.toggleAnswer(${question.id}, ${answer.id})">
<span class="answer-text">${answer.answer_text}</span>
</label>
`).join('')}
</div>
`;
}
renderTextAnswer(question) {
return `
<div class="text-answer">
<textarea class="form-control" rows="4"
placeholder="Введите ваш ответ..."
onchange="window.setTextAnswer(${question.id}, this.value)">${this.answers[question.id] || ''}</textarea>
</div>
`;
}
renderMatch(question) {
// Shuffle right items for display
const rightItems = [...question.match_items].sort(() => Math.random() - 0.5);
return `
<div class="match-question">
<div class="match-columns">
<div class="match-column left-column">
${question.match_items.map(item => `
<div class="match-item" data-id="${item.id}">
<span>${item.left_text}</span>
<select onchange="window.selectMatch(${question.id}, ${item.id}, this.value)">
<option value="">Выберите...</option>
${rightItems.map(r => `
<option value="${r.right_text}" ${this.answers[question.id]?.[item.id] === r.right_text ? 'selected' : ''}>
${r.right_text}
</option>
`).join('')}
</select>
</div>
`).join('')}
</div>
</div>
</div>
`;
}
renderResults() {
const lastAttempt = this.attempts[0];
if (!lastAttempt) return '<p>Результаты не найдены</p>';
const percentage = Math.round((lastAttempt.score / lastAttempt.max_score) * 100);
return `
<div class="test-results">
<div class="results-header ${lastAttempt.passed ? 'passed' : 'failed'}">
<h2>${lastAttempt.passed ? '✓ Тест пройден!' : '✗ Тест не пройден'}</h2>
<div class="score-circle">
<span class="score-value">${percentage}%</span>
</div>
</div>
<div class="results-details">
<p>Набрано баллов: ${lastAttempt.score} из ${lastAttempt.max_score}</p>
<p>Проходной балл: ${this.settings.passing_score}%</p>
</div>
${this.settings.max_attempts > 0 ? `
<p class="attempts-info">Попыток использовано: ${this.attempts.length} из ${this.settings.max_attempts}</p>
` : ''}
${!lastAttempt.passed && (this.settings.max_attempts === 0 || this.attempts.length < this.settings.max_attempts) ? `
<button class="btn btn-primary" onclick="window.location.reload()">
Попробовать снова
</button>
` : ''}
</div>
`;
}
showError(message) {
this.container.innerHTML = `
<div class="test-error">
<p>${message}</p>
</div>
`;
}
async submitTest() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
}
// Validate all questions are answered
const unanswered = this.questions.filter(q => {
const answer = this.answers[q.id];
if (q.question_type === 'single_choice') return answer === null;
if (q.question_type === 'multiple_choice') return answer.length === 0;
if (q.question_type === 'text_answer') return !answer.trim();
if (q.question_type === 'match') return Object.keys(answer).length === 0;
return false;
});
if (unanswered.length > 0 && !confirm(`Осталось ${unanswered.length} без ответа. Продолжить?`)) {
return;
}
try {
const response = await coursesAPI.submitTest(this.stepId, this.answers);
if (response && response.attempt) {
this.attempts.unshift(response.attempt);
this.isSubmitted = true;
this.container.innerHTML = this.renderResults();
}
} catch (error) {
alert('Ошибка отправки теста: ' + error.message);
}
}
destroy() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
}
}
}
// Global functions
window.prevQuestion = function() {
if (!window.currentTestViewer) return;
if (window.currentTestViewer.currentQuestionIndex > 0) {
window.currentTestViewer.currentQuestionIndex--;
window.currentTestViewer.render();
}
};
window.nextQuestion = function() {
if (!window.currentTestViewer) return;
if (window.currentTestViewer.currentQuestionIndex < window.currentTestViewer.questions.length - 1) {
window.currentTestViewer.currentQuestionIndex++;
window.currentTestViewer.render();
}
};
window.goToQuestion = function(index) {
if (!window.currentTestViewer) return;
window.currentTestViewer.currentQuestionIndex = index;
window.currentTestViewer.render();
};
window.selectAnswer = function(questionId, answerId) {
if (!window.currentTestViewer) return;
window.currentTestViewer.answers[questionId] = answerId;
};
window.toggleAnswer = function(questionId, answerId) {
if (!window.currentTestViewer) return;
const answers = window.currentTestViewer.answers[questionId];
const index = answers.indexOf(answerId);
if (index > -1) {
answers.splice(index, 1);
} else {
answers.push(answerId);
}
};
window.setTextAnswer = function(questionId, text) {
if (!window.currentTestViewer) return;
window.currentTestViewer.answers[questionId] = text;
};
window.selectMatch = function(questionId, leftId, rightValue) {
if (!window.currentTestViewer) return;
if (!window.currentTestViewer.answers[questionId]) {
window.currentTestViewer.answers[questionId] = {};
}
window.currentTestViewer.answers[questionId][leftId] = rightValue;
};
window.submitTest = function() {
if (!window.currentTestViewer) return;
window.currentTestViewer.submitTest();
};