Files
bot-telegram/app.control-bot/web/static/js/app.js
2025-09-22 11:54:42 +05:00

959 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Основное приложение для управления тестами
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;