add algam
This commit is contained in:
304
app.control-bot/web/static/js/api.js
Normal file
304
app.control-bot/web/static/js/api.js
Normal file
@@ -0,0 +1,304 @@
|
||||
// API конфигурация
|
||||
const API_BASE_URL = 'http://localhost:8000/api/v1';
|
||||
|
||||
// Класс для работы с API
|
||||
class API {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
// Общий метод для HTTP запросов
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Для некоторых запросов может быть пустой ответ
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// GET запрос
|
||||
async get(endpoint, params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||
return this.request(url);
|
||||
}
|
||||
|
||||
// POST запрос
|
||||
async post(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// PUT запрос
|
||||
async put(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE запрос
|
||||
async delete(endpoint) {
|
||||
return this.request(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Загрузка файла
|
||||
async uploadFile(endpoint, formData) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {} // Убираем Content-Type для FormData
|
||||
});
|
||||
}
|
||||
|
||||
// === Методы для работы с тестами ===
|
||||
|
||||
async getTests(params = {}) {
|
||||
return this.get('/tests', params);
|
||||
}
|
||||
|
||||
async getTestById(id) {
|
||||
return this.get(`/tests/${id}`);
|
||||
}
|
||||
|
||||
async createTest(testData) {
|
||||
return this.post('/tests', testData);
|
||||
}
|
||||
|
||||
async updateTest(id, testData) {
|
||||
return this.put(`/tests/${id}`, testData);
|
||||
}
|
||||
|
||||
async deleteTest(id) {
|
||||
return this.delete(`/tests/${id}`);
|
||||
}
|
||||
|
||||
// === Методы для работы с категориями ===
|
||||
|
||||
async getCategories() {
|
||||
return this.get('/test-categories');
|
||||
}
|
||||
|
||||
async getCategoryById(id) {
|
||||
return this.get(`/test-categories/${id}`);
|
||||
}
|
||||
|
||||
async createCategory(categoryData) {
|
||||
return this.post('/test-categories', categoryData);
|
||||
}
|
||||
|
||||
async updateCategory(id, categoryData) {
|
||||
return this.put(`/test-categories/${id}`, categoryData);
|
||||
}
|
||||
|
||||
async deleteCategory(id) {
|
||||
return this.delete(`/test-categories/${id}`);
|
||||
}
|
||||
|
||||
// === Методы для работы с вопросами ===
|
||||
|
||||
async getQuestionsByTest(testId) {
|
||||
return this.get(`/questions/test/${testId}`);
|
||||
}
|
||||
|
||||
async getQuestionById(id) {
|
||||
return this.get(`/questions/${id}`);
|
||||
}
|
||||
|
||||
async createQuestion(questionData) {
|
||||
return this.post('/questions', questionData);
|
||||
}
|
||||
|
||||
async updateQuestion(id, questionData) {
|
||||
return this.put(`/questions/${id}`, questionData);
|
||||
}
|
||||
|
||||
async deleteQuestion(id) {
|
||||
return this.delete(`/questions/${id}`);
|
||||
}
|
||||
|
||||
// === Методы для работы с вариантами ответов ===
|
||||
|
||||
async getAnswerOptionsByQuestion(questionId) {
|
||||
return this.get(`/answer-options/question/${questionId}`);
|
||||
}
|
||||
|
||||
async createAnswerOption(optionData) {
|
||||
return this.post('/answer-options', optionData);
|
||||
}
|
||||
|
||||
async updateAnswerOption(id, optionData) {
|
||||
return this.put(`/answer-options/${id}`, optionData);
|
||||
}
|
||||
|
||||
async deleteAnswerOption(id) {
|
||||
return this.delete(`/answer-options/${id}`);
|
||||
}
|
||||
|
||||
// === Методы для работы с изображениями ===
|
||||
|
||||
async uploadImage(file, altText = null) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (altText) {
|
||||
formData.append('alt_text', altText);
|
||||
}
|
||||
return this.uploadFile('/images/upload', formData);
|
||||
}
|
||||
|
||||
async getImage(imageId) {
|
||||
return this.request(`/images/${imageId}`, {
|
||||
headers: {}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteImage(id) {
|
||||
return this.delete(`/images/${id}`);
|
||||
}
|
||||
|
||||
// === Служебные методы ===
|
||||
|
||||
async checkHealth() {
|
||||
return this.get('/health');
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем глобальный экземпляр API
|
||||
const api = new API(API_BASE_URL);
|
||||
|
||||
// Утилиты для работы с API
|
||||
|
||||
// Показать уведомление об ошибке
|
||||
function showApiError(error, customMessage = null) {
|
||||
const message = customMessage || error.message || 'Произошла ошибка при обращении к API';
|
||||
showNotification(message, 'danger');
|
||||
console.error('API Error:', error);
|
||||
}
|
||||
|
||||
// Показать уведомление об успехе
|
||||
function showApiSuccess(message) {
|
||||
showNotification(message, 'success');
|
||||
}
|
||||
|
||||
// Показать загрузку
|
||||
function showLoading(show = true) {
|
||||
const spinner = document.getElementById('loadingSpinner');
|
||||
if (spinner) {
|
||||
spinner.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для показа уведомлений
|
||||
function showNotification(message, type = 'info', duration = 5000) {
|
||||
const notificationsContainer = document.getElementById('notifications');
|
||||
if (!notificationsContainer) return;
|
||||
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
alertDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
notificationsContainer.appendChild(alertDiv);
|
||||
|
||||
// Автоматически удаляем уведомление через заданное время
|
||||
setTimeout(() => {
|
||||
if (alertDiv && alertDiv.parentNode) {
|
||||
alertDiv.remove();
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Форматирование даты
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Получение URL изображения
|
||||
function getImageUrl(imageId) {
|
||||
return `${API_BASE_URL}/images/${imageId}`;
|
||||
}
|
||||
|
||||
// Валидация данных формы
|
||||
function validateForm(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return false;
|
||||
|
||||
const requiredFields = form.querySelectorAll('[required]');
|
||||
let isValid = true;
|
||||
|
||||
requiredFields.forEach(field => {
|
||||
field.classList.remove('is-invalid');
|
||||
|
||||
if (!field.value.trim()) {
|
||||
field.classList.add('is-invalid');
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Очистка формы
|
||||
function clearForm(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
form.reset();
|
||||
|
||||
// Удаляем классы валидации
|
||||
const fields = form.querySelectorAll('.is-invalid');
|
||||
fields.forEach(field => field.classList.remove('is-invalid'));
|
||||
|
||||
// Очищаем скрытые поля
|
||||
const hiddenFields = form.querySelectorAll('input[type="hidden"]');
|
||||
hiddenFields.forEach(field => field.value = '');
|
||||
}
|
||||
|
||||
// Экспорт для использования в других скриптах
|
||||
window.api = api;
|
||||
window.showApiError = showApiError;
|
||||
window.showApiSuccess = showApiSuccess;
|
||||
window.showLoading = showLoading;
|
||||
window.showNotification = showNotification;
|
||||
window.formatDate = formatDate;
|
||||
window.getImageUrl = getImageUrl;
|
||||
window.validateForm = validateForm;
|
||||
window.clearForm = clearForm;
|
||||
959
app.control-bot/web/static/js/app.js
Normal file
959
app.control-bot/web/static/js/app.js
Normal file
@@ -0,0 +1,959 @@
|
||||
// Основное приложение для управления тестами
|
||||
|
||||
class TestManager {
|
||||
constructor() {
|
||||
this.currentPanel = 'tests';
|
||||
this.categories = [];
|
||||
this.tests = [];
|
||||
this.questions = [];
|
||||
this.selectedTestId = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.setupEventListeners();
|
||||
await this.loadInitialData();
|
||||
this.showPanel('tests');
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Навигация
|
||||
document.getElementById('nav-tests').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.showPanel('tests');
|
||||
});
|
||||
|
||||
document.getElementById('nav-categories').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.showPanel('categories');
|
||||
});
|
||||
|
||||
document.getElementById('nav-questions').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.showPanel('questions');
|
||||
});
|
||||
|
||||
// Кнопки добавления
|
||||
document.getElementById('add-test-btn').addEventListener('click', () => {
|
||||
this.showTestModal();
|
||||
});
|
||||
|
||||
document.getElementById('add-category-btn').addEventListener('click', () => {
|
||||
this.showCategoryModal();
|
||||
});
|
||||
|
||||
document.getElementById('add-question-btn').addEventListener('click', () => {
|
||||
this.showQuestionModal();
|
||||
});
|
||||
|
||||
// Кнопки сохранения
|
||||
document.getElementById('saveTestBtn').addEventListener('click', () => {
|
||||
this.saveTest();
|
||||
});
|
||||
|
||||
document.getElementById('saveCategoryBtn').addEventListener('click', () => {
|
||||
this.saveCategory();
|
||||
});
|
||||
|
||||
document.getElementById('saveQuestionBtn').addEventListener('click', () => {
|
||||
this.saveQuestion();
|
||||
});
|
||||
|
||||
// Фильтры
|
||||
document.getElementById('category-filter').addEventListener('change', () => {
|
||||
this.loadTests();
|
||||
});
|
||||
|
||||
document.getElementById('status-filter').addEventListener('change', () => {
|
||||
this.loadTests();
|
||||
});
|
||||
|
||||
document.getElementById('search-input').addEventListener('input', () => {
|
||||
this.filterTests();
|
||||
});
|
||||
|
||||
// Выбор теста для вопросов
|
||||
document.getElementById('test-select').addEventListener('change', (e) => {
|
||||
this.selectedTestId = e.target.value;
|
||||
if (this.selectedTestId) {
|
||||
this.loadQuestions(this.selectedTestId);
|
||||
document.getElementById('add-question-btn').disabled = false;
|
||||
} else {
|
||||
document.getElementById('add-question-btn').disabled = true;
|
||||
this.showQuestionsEmptyState();
|
||||
}
|
||||
});
|
||||
|
||||
// Подтверждение удаления
|
||||
document.getElementById('confirmDeleteBtn').addEventListener('click', () => {
|
||||
this.executeDelete();
|
||||
});
|
||||
|
||||
// Работа с вариантами ответов
|
||||
document.getElementById('addOptionBtn').addEventListener('click', () => {
|
||||
this.addAnswerOption();
|
||||
});
|
||||
|
||||
// Загрузка изображения
|
||||
document.getElementById('questionImage').addEventListener('change', (e) => {
|
||||
this.previewImage(e.target.files[0]);
|
||||
});
|
||||
|
||||
document.getElementById('removeImageBtn').addEventListener('click', () => {
|
||||
this.removeImage();
|
||||
});
|
||||
|
||||
// Тип вопроса
|
||||
document.getElementById('questionType').addEventListener('change', (e) => {
|
||||
this.toggleAnswerOptions(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// === Управление панелями ===
|
||||
|
||||
showPanel(panelName) {
|
||||
// Скрываем все панели
|
||||
document.querySelectorAll('.content-panel').forEach(panel => {
|
||||
panel.style.display = 'none';
|
||||
});
|
||||
|
||||
// Показываем выбранную панель
|
||||
document.getElementById(`${panelName}-panel`).style.display = 'block';
|
||||
|
||||
// Обновляем навигацию
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
|
||||
document.getElementById(`nav-${panelName}`).classList.add('active');
|
||||
|
||||
this.currentPanel = panelName;
|
||||
|
||||
// Загружаем данные для панели
|
||||
this.loadPanelData(panelName);
|
||||
}
|
||||
|
||||
async loadPanelData(panelName) {
|
||||
switch (panelName) {
|
||||
case 'tests':
|
||||
await this.loadTests();
|
||||
break;
|
||||
case 'categories':
|
||||
await this.loadCategories();
|
||||
break;
|
||||
case 'questions':
|
||||
await this.loadTestsForSelect();
|
||||
this.showQuestionsEmptyState();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// === Загрузка начальных данных ===
|
||||
|
||||
async loadInitialData() {
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
// Проверяем подключение к API
|
||||
await api.checkHealth();
|
||||
|
||||
// Загружаем категории для фильтров
|
||||
this.categories = await api.getCategories();
|
||||
this.populateCategoryFilters();
|
||||
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при загрузке начальных данных');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
populateCategoryFilters() {
|
||||
const categoryFilter = document.getElementById('category-filter');
|
||||
const testCategorySelect = document.getElementById('testCategory');
|
||||
|
||||
// Очищаем существующие опции (кроме первой)
|
||||
while (categoryFilter.children.length > 1) {
|
||||
categoryFilter.removeChild(categoryFilter.lastChild);
|
||||
}
|
||||
|
||||
while (testCategorySelect.children.length > 1) {
|
||||
testCategorySelect.removeChild(testCategorySelect.lastChild);
|
||||
}
|
||||
|
||||
// Добавляем категории
|
||||
this.categories.forEach(category => {
|
||||
const option1 = new Option(category.name, category.id);
|
||||
const option2 = new Option(category.name, category.id);
|
||||
|
||||
categoryFilter.appendChild(option1);
|
||||
testCategorySelect.appendChild(option2);
|
||||
});
|
||||
}
|
||||
|
||||
// === Управление тестами ===
|
||||
|
||||
async loadTests() {
|
||||
try {
|
||||
const categoryId = document.getElementById('category-filter').value;
|
||||
const isActive = document.getElementById('status-filter').value;
|
||||
|
||||
const params = {};
|
||||
if (categoryId) params.category_id = categoryId;
|
||||
if (isActive !== '') params.is_active = isActive === 'true';
|
||||
|
||||
this.tests = await api.getTests(params);
|
||||
this.renderTestsTable();
|
||||
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при загрузке тестов');
|
||||
}
|
||||
}
|
||||
|
||||
renderTestsTable() {
|
||||
const tbody = document.getElementById('tests-table-body');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (this.tests.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
Тесты не найдены
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.tests.forEach(test => {
|
||||
const category = this.categories.find(c => c.id === test.category_id);
|
||||
const categoryName = category ? category.name : 'Без категории';
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${test.id}</td>
|
||||
<td>
|
||||
<strong>${test.title}</strong>
|
||||
${test.description ? `<br><small class="text-muted">${test.description}</small>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">${categoryName}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${test.is_active ? 'bg-success' : 'bg-secondary'}">
|
||||
${test.is_active ? 'Активный' : 'Неактивный'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${test.time_limit_minutes || '-'}</td>
|
||||
<td>
|
||||
<span class="badge bg-info" id="questions-count-${test.id}">0</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">${formatDate(test.created_at)}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="testManager.editTest(${test.id})">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info" onclick="testManager.viewQuestions(${test.id})">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="testManager.confirmDeleteTest(${test.id})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
||||
// Загружаем количество вопросов для каждого теста
|
||||
this.loadQuestionsCount(test.id);
|
||||
});
|
||||
}
|
||||
|
||||
async loadQuestionsCount(testId) {
|
||||
try {
|
||||
const questions = await api.getQuestionsByTest(testId);
|
||||
const countElement = document.getElementById(`questions-count-${testId}`);
|
||||
if (countElement) {
|
||||
countElement.textContent = questions.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading questions count:', error);
|
||||
}
|
||||
}
|
||||
|
||||
filterTests() {
|
||||
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
||||
const rows = document.querySelectorAll('#tests-table-body tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const title = row.cells[1]?.textContent.toLowerCase() || '';
|
||||
const visible = title.includes(searchTerm);
|
||||
row.style.display = visible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
showTestModal(test = null) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('testModal'));
|
||||
const title = document.getElementById('testModalTitle');
|
||||
|
||||
if (test) {
|
||||
title.textContent = 'Редактировать тест';
|
||||
this.fillTestForm(test);
|
||||
} else {
|
||||
title.textContent = 'Добавить тест';
|
||||
clearForm('testForm');
|
||||
}
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
fillTestForm(test) {
|
||||
document.getElementById('testId').value = test.id;
|
||||
document.getElementById('testTitle').value = test.title;
|
||||
document.getElementById('testDescription').value = test.description || '';
|
||||
document.getElementById('testCategory').value = test.category_id || '';
|
||||
document.getElementById('testTimeLimit').value = test.time_limit_minutes || '';
|
||||
document.getElementById('testIsActive').checked = test.is_active;
|
||||
}
|
||||
|
||||
async saveTest() {
|
||||
if (!validateForm('testForm')) {
|
||||
showNotification('Заполните все обязательные поля', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const testId = document.getElementById('testId').value;
|
||||
const testData = {
|
||||
title: document.getElementById('testTitle').value.trim(),
|
||||
description: document.getElementById('testDescription').value.trim() || null,
|
||||
category_id: document.getElementById('testCategory').value || null,
|
||||
time_limit_minutes: document.getElementById('testTimeLimit').value || null,
|
||||
is_active: document.getElementById('testIsActive').checked
|
||||
};
|
||||
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
if (testId) {
|
||||
await api.updateTest(testId, testData);
|
||||
showApiSuccess('Тест успешно обновлен');
|
||||
} else {
|
||||
await api.createTest(testData);
|
||||
showApiSuccess('Тест успешно создан');
|
||||
}
|
||||
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('testModal'));
|
||||
modal.hide();
|
||||
|
||||
await this.loadTests();
|
||||
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при сохранении теста');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async editTest(testId) {
|
||||
try {
|
||||
const test = await api.getTestById(testId);
|
||||
this.showTestModal(test);
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при загрузке теста');
|
||||
}
|
||||
}
|
||||
|
||||
confirmDeleteTest(testId) {
|
||||
this.showDeleteConfirmation(
|
||||
'test',
|
||||
testId,
|
||||
'Вы уверены, что хотите удалить этот тест? Все связанные вопросы и ответы также будут удалены.'
|
||||
);
|
||||
}
|
||||
|
||||
async deleteTest(testId) {
|
||||
try {
|
||||
await api.deleteTest(testId);
|
||||
showApiSuccess('Тест успешно удален');
|
||||
await this.loadTests();
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при удалении теста');
|
||||
}
|
||||
}
|
||||
|
||||
viewQuestions(testId) {
|
||||
this.selectedTestId = testId;
|
||||
document.getElementById('test-select').value = testId;
|
||||
this.showPanel('questions');
|
||||
this.loadQuestions(testId);
|
||||
document.getElementById('add-question-btn').disabled = false;
|
||||
}
|
||||
|
||||
// === Управление категориями ===
|
||||
|
||||
async loadCategories() {
|
||||
try {
|
||||
this.categories = await api.getCategories();
|
||||
this.renderCategoriesTable();
|
||||
this.populateCategoryFilters();
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при загрузке категорий');
|
||||
}
|
||||
}
|
||||
|
||||
renderCategoriesTable() {
|
||||
const tbody = document.getElementById('categories-table-body');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (this.categories.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
<i class="bi bi-tags fs-1 d-block mb-2"></i>
|
||||
Категории не найдены
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.categories.forEach(category => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${category.id}</td>
|
||||
<td>
|
||||
<strong>${category.name}</strong>
|
||||
</td>
|
||||
<td>
|
||||
${category.description || '-'}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info" id="category-tests-count-${category.id}">0</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">${formatDate(category.created_at)}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="testManager.editCategory(${category.id})">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="testManager.confirmDeleteCategory(${category.id})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
||||
// Подсчитываем количество тестов в категории
|
||||
this.loadCategoryTestsCount(category.id);
|
||||
});
|
||||
}
|
||||
|
||||
loadCategoryTestsCount(categoryId) {
|
||||
const testsCount = this.tests.filter(test => test.category_id === categoryId).length;
|
||||
const countElement = document.getElementById(`category-tests-count-${categoryId}`);
|
||||
if (countElement) {
|
||||
countElement.textContent = testsCount;
|
||||
}
|
||||
}
|
||||
|
||||
showCategoryModal(category = null) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('categoryModal'));
|
||||
const title = document.getElementById('categoryModalTitle');
|
||||
|
||||
if (category) {
|
||||
title.textContent = 'Редактировать категорию';
|
||||
this.fillCategoryForm(category);
|
||||
} else {
|
||||
title.textContent = 'Добавить категорию';
|
||||
clearForm('categoryForm');
|
||||
}
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
fillCategoryForm(category) {
|
||||
document.getElementById('categoryId').value = category.id;
|
||||
document.getElementById('categoryName').value = category.name;
|
||||
document.getElementById('categoryDescription').value = category.description || '';
|
||||
}
|
||||
|
||||
async saveCategory() {
|
||||
if (!validateForm('categoryForm')) {
|
||||
showNotification('Заполните все обязательные поля', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryId = document.getElementById('categoryId').value;
|
||||
const categoryData = {
|
||||
name: document.getElementById('categoryName').value.trim(),
|
||||
description: document.getElementById('categoryDescription').value.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
if (categoryId) {
|
||||
await api.updateCategory(categoryId, categoryData);
|
||||
showApiSuccess('Категория успешно обновлена');
|
||||
} else {
|
||||
await api.createCategory(categoryData);
|
||||
showApiSuccess('Категория успешно создана');
|
||||
}
|
||||
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('categoryModal'));
|
||||
modal.hide();
|
||||
|
||||
await this.loadCategories();
|
||||
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при сохранении категории');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async editCategory(categoryId) {
|
||||
try {
|
||||
const category = await api.getCategoryById(categoryId);
|
||||
this.showCategoryModal(category);
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при загрузке категории');
|
||||
}
|
||||
}
|
||||
|
||||
confirmDeleteCategory(categoryId) {
|
||||
this.showDeleteConfirmation(
|
||||
'category',
|
||||
categoryId,
|
||||
'Вы уверены, что хотите удалить эту категорию? Все тесты в ней останутся без категории.'
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCategory(categoryId) {
|
||||
try {
|
||||
await api.deleteCategory(categoryId);
|
||||
showApiSuccess('Категория успешно удалена');
|
||||
await this.loadCategories();
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при удалении категории');
|
||||
}
|
||||
}
|
||||
|
||||
// === Управление вопросами ===
|
||||
|
||||
async loadTestsForSelect() {
|
||||
try {
|
||||
const tests = await api.getTests({ is_active: true });
|
||||
const select = document.getElementById('test-select');
|
||||
|
||||
// Очищаем существующие опции (кроме первой)
|
||||
while (select.children.length > 1) {
|
||||
select.removeChild(select.lastChild);
|
||||
}
|
||||
|
||||
tests.forEach(test => {
|
||||
const option = new Option(test.title, test.id);
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при загрузке списка тестов');
|
||||
}
|
||||
}
|
||||
|
||||
async loadQuestions(testId) {
|
||||
try {
|
||||
this.questions = await api.getQuestionsByTest(testId);
|
||||
this.renderQuestions();
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при загрузке вопросов');
|
||||
}
|
||||
}
|
||||
|
||||
renderQuestions() {
|
||||
const container = document.getElementById('questions-content');
|
||||
|
||||
if (this.questions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
В этом тесте пока нет вопросов. Добавьте первый вопрос.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
this.questions
|
||||
.sort((a, b) => a.order_number - b.order_number)
|
||||
.forEach(async (question, index) => {
|
||||
const questionDiv = document.createElement('div');
|
||||
questionDiv.className = 'question-item';
|
||||
questionDiv.innerHTML = `
|
||||
<div class="question-header">
|
||||
<div>
|
||||
<span class="badge bg-primary me-2">${question.order_number}</span>
|
||||
<span class="badge bg-secondary me-2">${this.getQuestionTypeLabel(question.question_type)}</span>
|
||||
<span class="badge bg-info">${question.points} балл${question.points === 1 ? '' : question.points < 5 ? 'а' : 'ов'}</span>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="testManager.editQuestion(${question.id})">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="testManager.confirmDeleteQuestion(${question.id})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="question-text">${question.question_text}</div>
|
||||
${question.image_id ? `<img src="${getImageUrl(question.image_id)}" class="question-image" alt="Изображение к вопросу">` : ''}
|
||||
<div class="question-options" id="options-${question.id}">
|
||||
<div class="text-muted">Загрузка вариантов ответов...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(questionDiv);
|
||||
|
||||
// Загружаем варианты ответов
|
||||
this.loadQuestionOptions(question.id);
|
||||
});
|
||||
}
|
||||
|
||||
async loadQuestionOptions(questionId) {
|
||||
try {
|
||||
const options = await api.getAnswerOptionsByQuestion(questionId);
|
||||
const container = document.getElementById(`options-${questionId}`);
|
||||
|
||||
if (options.length === 0) {
|
||||
container.innerHTML = '<div class="text-muted"><i>Нет вариантов ответов</i></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
options
|
||||
.sort((a, b) => a.order_number - b.order_number)
|
||||
.forEach(option => {
|
||||
const optionDiv = document.createElement('div');
|
||||
optionDiv.className = `option-item ${option.is_correct ? 'correct-option' : 'incorrect-option'}`;
|
||||
optionDiv.innerHTML = `
|
||||
<i class="bi bi-${option.is_correct ? 'check-circle-fill text-success' : 'circle'} me-2"></i>
|
||||
${option.option_text}
|
||||
`;
|
||||
container.appendChild(optionDiv);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading question options:', error);
|
||||
const container = document.getElementById(`options-${questionId}`);
|
||||
container.innerHTML = '<div class="text-danger"><i>Ошибка загрузки вариантов</i></div>';
|
||||
}
|
||||
}
|
||||
|
||||
getQuestionTypeLabel(type) {
|
||||
const labels = {
|
||||
'single_choice': 'Один ответ',
|
||||
'multiple_choice': 'Несколько ответов',
|
||||
'text_input': 'Текстовый ввод'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
showQuestionsEmptyState() {
|
||||
document.getElementById('questions-content').innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Выберите тест для просмотра и редактирования вопросов
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showQuestionModal(question = null) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('questionModal'));
|
||||
const title = document.getElementById('questionModalTitle');
|
||||
|
||||
if (question) {
|
||||
title.textContent = 'Редактировать вопрос';
|
||||
this.fillQuestionForm(question);
|
||||
} else {
|
||||
title.textContent = 'Добавить вопрос';
|
||||
clearForm('questionForm');
|
||||
document.getElementById('questionTestId').value = this.selectedTestId;
|
||||
// Устанавливаем следующий порядковый номер
|
||||
const nextOrder = Math.max(...this.questions.map(q => q.order_number), 0) + 1;
|
||||
document.getElementById('questionOrder').value = nextOrder;
|
||||
this.resetAnswerOptions();
|
||||
}
|
||||
|
||||
this.toggleAnswerOptions(document.getElementById('questionType').value);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
fillQuestionForm(question) {
|
||||
document.getElementById('questionId').value = question.id;
|
||||
document.getElementById('questionTestId').value = question.test_id;
|
||||
document.getElementById('questionText').value = question.question_text;
|
||||
document.getElementById('questionType').value = question.question_type;
|
||||
document.getElementById('questionOrder').value = question.order_number;
|
||||
document.getElementById('questionPoints').value = question.points;
|
||||
|
||||
if (question.image_id) {
|
||||
document.getElementById('currentImage').style.display = 'block';
|
||||
document.getElementById('imagePreview').src = getImageUrl(question.image_id);
|
||||
document.getElementById('imagePreview').dataset.imageId = question.image_id;
|
||||
} else {
|
||||
document.getElementById('currentImage').style.display = 'none';
|
||||
}
|
||||
|
||||
// Загружаем варианты ответов
|
||||
this.loadQuestionOptionsForEdit(question.id);
|
||||
}
|
||||
|
||||
async loadQuestionOptionsForEdit(questionId) {
|
||||
try {
|
||||
const options = await api.getAnswerOptionsByQuestion(questionId);
|
||||
this.resetAnswerOptions();
|
||||
|
||||
options
|
||||
.sort((a, b) => a.order_number - b.order_number)
|
||||
.forEach(option => {
|
||||
this.addAnswerOption(option);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading options for edit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
toggleAnswerOptions(questionType) {
|
||||
const optionsContainer = document.getElementById('answerOptions');
|
||||
|
||||
if (questionType === 'text_input') {
|
||||
optionsContainer.style.display = 'none';
|
||||
} else {
|
||||
optionsContainer.style.display = 'block';
|
||||
|
||||
// Если нет вариантов, добавляем первый
|
||||
if (document.getElementById('optionsList').children.length === 0) {
|
||||
this.addAnswerOption();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetAnswerOptions() {
|
||||
document.getElementById('optionsList').innerHTML = '';
|
||||
document.getElementById('currentImage').style.display = 'none';
|
||||
document.getElementById('questionImage').value = '';
|
||||
}
|
||||
|
||||
addAnswerOption(optionData = null) {
|
||||
const optionsList = document.getElementById('optionsList');
|
||||
const optionIndex = optionsList.children.length;
|
||||
|
||||
const optionDiv = document.createElement('div');
|
||||
optionDiv.className = 'answer-option';
|
||||
optionDiv.innerHTML = `
|
||||
<div class="option-controls">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="option-correct-${optionIndex}" ${optionData?.is_correct ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="option-correct-${optionIndex}">
|
||||
Правильный ответ
|
||||
</label>
|
||||
</div>
|
||||
<input type="text" class="form-control option-text" placeholder="Текст варианта ответа" value="${optionData?.option_text || ''}" required>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.parentElement.parentElement.remove()">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" class="option-id" value="${optionData?.id || ''}">
|
||||
`;
|
||||
|
||||
optionsList.appendChild(optionDiv);
|
||||
}
|
||||
|
||||
previewImage(file) {
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
document.getElementById('currentImage').style.display = 'block';
|
||||
document.getElementById('imagePreview').src = e.target.result;
|
||||
document.getElementById('imagePreview').dataset.imageId = '';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
removeImage() {
|
||||
document.getElementById('currentImage').style.display = 'none';
|
||||
document.getElementById('questionImage').value = '';
|
||||
document.getElementById('imagePreview').dataset.imageId = '';
|
||||
}
|
||||
|
||||
async saveQuestion() {
|
||||
if (!validateForm('questionForm')) {
|
||||
showNotification('Заполните все обязательные поля', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const questionId = document.getElementById('questionId').value;
|
||||
const imageFile = document.getElementById('questionImage').files[0];
|
||||
let imageId = document.getElementById('imagePreview').dataset.imageId || null;
|
||||
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
// Загружаем изображение, если выбрано новое
|
||||
if (imageFile) {
|
||||
const imageResult = await api.uploadImage(imageFile);
|
||||
imageId = imageResult.id;
|
||||
}
|
||||
|
||||
const questionData = {
|
||||
test_id: parseInt(document.getElementById('questionTestId').value),
|
||||
question_text: document.getElementById('questionText').value.trim(),
|
||||
question_type: document.getElementById('questionType').value,
|
||||
order_number: parseInt(document.getElementById('questionOrder').value),
|
||||
points: parseInt(document.getElementById('questionPoints').value),
|
||||
image_id: imageId ? parseInt(imageId) : null
|
||||
};
|
||||
|
||||
let savedQuestion;
|
||||
if (questionId) {
|
||||
savedQuestion = await api.updateQuestion(questionId, questionData);
|
||||
showApiSuccess('Вопрос успешно обновлен');
|
||||
} else {
|
||||
savedQuestion = await api.createQuestion(questionData);
|
||||
showApiSuccess('Вопрос успешно создан');
|
||||
}
|
||||
|
||||
// Сохраняем варианты ответов
|
||||
if (questionData.question_type !== 'text_input') {
|
||||
await this.saveAnswerOptions(savedQuestion.id || questionId);
|
||||
}
|
||||
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('questionModal'));
|
||||
modal.hide();
|
||||
|
||||
await this.loadQuestions(this.selectedTestId);
|
||||
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при сохранении вопроса');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async saveAnswerOptions(questionId) {
|
||||
const optionElements = document.querySelectorAll('#optionsList .answer-option');
|
||||
|
||||
for (let i = 0; i < optionElements.length; i++) {
|
||||
const element = optionElements[i];
|
||||
const optionId = element.querySelector('.option-id').value;
|
||||
const optionText = element.querySelector('.option-text').value.trim();
|
||||
const isCorrect = element.querySelector(`#option-correct-${i}`).checked;
|
||||
|
||||
if (!optionText) continue;
|
||||
|
||||
const optionData = {
|
||||
question_id: questionId,
|
||||
option_text: optionText,
|
||||
is_correct: isCorrect,
|
||||
order_number: i + 1
|
||||
};
|
||||
|
||||
try {
|
||||
if (optionId) {
|
||||
await api.updateAnswerOption(optionId, optionData);
|
||||
} else {
|
||||
await api.createAnswerOption(optionData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving answer option:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async editQuestion(questionId) {
|
||||
try {
|
||||
const question = await api.getQuestionById(questionId);
|
||||
this.showQuestionModal(question);
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при загрузке вопроса');
|
||||
}
|
||||
}
|
||||
|
||||
confirmDeleteQuestion(questionId) {
|
||||
this.showDeleteConfirmation(
|
||||
'question',
|
||||
questionId,
|
||||
'Вы уверены, что хотите удалить этот вопрос? Все варианты ответов также будут удалены.'
|
||||
);
|
||||
}
|
||||
|
||||
async deleteQuestion(questionId) {
|
||||
try {
|
||||
await api.deleteQuestion(questionId);
|
||||
showApiSuccess('Вопрос успешно удален');
|
||||
await this.loadQuestions(this.selectedTestId);
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при удалении вопроса');
|
||||
}
|
||||
}
|
||||
|
||||
// === Подтверждение удаления ===
|
||||
|
||||
showDeleteConfirmation(type, id, message) {
|
||||
this.deleteType = type;
|
||||
this.deleteId = id;
|
||||
|
||||
document.getElementById('deleteConfirmText').textContent = message;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async executeDelete() {
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
switch (this.deleteType) {
|
||||
case 'test':
|
||||
await this.deleteTest(this.deleteId);
|
||||
break;
|
||||
case 'category':
|
||||
await this.deleteCategory(this.deleteId);
|
||||
break;
|
||||
case 'question':
|
||||
await this.deleteQuestion(this.deleteId);
|
||||
break;
|
||||
}
|
||||
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmDeleteModal'));
|
||||
modal.hide();
|
||||
|
||||
} catch (error) {
|
||||
showApiError(error, 'Ошибка при удалении');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем глобальный экземпляр менеджера тестов
|
||||
let testManager;
|
||||
|
||||
// Инициализация при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
testManager = new TestManager();
|
||||
});
|
||||
|
||||
// Экспорт для использования в HTML
|
||||
window.testManager = testManager;
|
||||
Reference in New Issue
Block a user