add algam

This commit is contained in:
2025-09-22 11:54:42 +05:00
parent 3c418a97b4
commit 5b9c55a4dc
34 changed files with 4700 additions and 426 deletions

97
app.control-bot/DOCKER.md Normal file
View File

@@ -0,0 +1,97 @@
# Запуск веб-интерфейса через Docker
## Предварительные требования
1. **Docker** и **Docker Compose** установлены
2. **API сервер** запущен отдельно на `http://localhost:8000`
## Запуск
### 1. Запуск API сервера (в отдельном терминале)
```bash
cd ../api
python run_api.py
```
### 2. Запуск веб-интерфейса через Docker
```bash
# Сборка и запуск контейнера
docker-compose up -d
# Или с пересборкой
docker-compose up -d --build
```
Веб-интерфейс будет доступен по адресу: `http://localhost:3000`
## Управление контейнером
### Просмотр логов
```bash
docker-compose logs -f web
```
### Остановка
```bash
docker-compose down
```
### Перезапуск
```bash
docker-compose restart
```
### Полная очистка
```bash
docker-compose down -v
docker system prune -f
```
## Структура
```
app.control-bot/
├── docker-compose.yml # Конфигурация Docker Compose
├── nginx.conf # Конфигурация Nginx
├── web/ # Статические файлы веб-интерфейса
│ ├── index.html
│ └── static/
└── DOCKER.md # Эта инструкция
```
## Особенности конфигурации
- **Nginx Alpine** - легковесный образ
- **Порт 3000** - веб-интерфейс
- **Статические файлы** из папки `./web`
- **Кэширование** статических ресурсов
- **CORS заголовки** для работы с внешним API
- **Сжатие gzip** для лучшей производительности
- **Безопасные заголовки**
## Проверка работы
1. Откройте `http://localhost:3000`
2. Проверьте, что API доступен на `http://localhost:8000`
3. Убедитесь, что веб-интерфейс может подключиться к API
## Устранение проблем
### Порт уже используется
```bash
# Измените порт в docker-compose.yml
ports:
- "3001:80" # Вместо 3000
```
### API недоступен
Убедитесь, что API сервер запущен и доступен на `http://localhost:8000`
### Проблемы с правами доступа
```bash
# В Windows может потребоваться запуск от администратора
# В Linux/Mac проверьте права на папки
chmod -R 755 ./web
```

159
app.control-bot/README.md Normal file
View File

@@ -0,0 +1,159 @@
# Управляющее приложение для Telegram бота
Веб-интерфейс для управления тестами Telegram бота.
## Возможности
- ✅ Управление тестами (создание, редактирование, удаление)
- ✅ Управление категориями тестов
- ✅ Управление вопросами и вариантами ответов
- ✅ Загрузка изображений к вопросам
- ✅ Фильтрация и поиск
- ✅ Современный адаптивный интерфейс
## Структура проекта
```
app.control-bot/
├── main.py # Основное приложение с веб-сервером
├── web/ # Веб-интерфейс
│ ├── index.html # Главная страница
│ └── static/
│ ├── css/
│ │ └── style.css # Стили
│ └── js/
│ ├── api.js # Работа с API
│ └── app.js # Основная логика приложения
├── pyproject.toml # Конфигурация проекта
└── README.md # Документация
```
## Запуск
### Предварительные требования
1. **Запущенный API сервер** на `http://localhost:8000`
```bash
cd ../api
python run_api.py
```
2. **Python 3.12+** (базовые библиотеки)
### Запуск веб-интерфейса
```bash
cd app.control-bot
python main.py
```
Веб-интерфейс будет доступен по адресу: `http://localhost:3000`
Браузер откроется автоматически через 2 секунды после запуска.
## Использование
### Управление тестами
1. **Создание теста:**
- Нажмите "Добавить тест"
- Заполните название и описание
- Выберите категорию (опционально)
- Установите лимит времени (опционально)
- Выберите статус (активный/неактивный)
2. **Редактирование теста:**
- Нажмите кнопку "Редактировать" в строке теста
- Измените необходимые поля
- Сохраните изменения
3. **Удаление теста:**
- Нажмите кнопку "Удалить" в строке теста
- Подтвердите удаление
### Управление категориями
1. Перейдите на вкладку "Категории"
2. Используйте кнопку "Добавить категорию" для создания новой
3. Редактируйте или удаляйте существующие категории
### Управление вопросами
1. Перейдите на вкладку "Вопросы"
2. Выберите тест из выпадающего списка
3. Добавляйте вопросы с помощью кнопки "Добавать вопрос"
#### Типы вопросов:
- **Один ответ** - радиокнопки, один правильный ответ
- **Несколько ответов** - чекбоксы, может быть несколько правильных
- **Текстовый ввод** - пользователь вводит ответ текстом
#### Работа с изображениями:
- Загружайте изображения к вопросам
- Поддерживаются форматы: JPG, PNG, GIF, WebP
- Изображения сохраняются в базе данных
## API Integration
Веб-интерфейс работает с API по адресу `http://localhost:8000/api/v1`
### Используемые эндпоинты:
- `GET/POST /tests` - Управление тестами
- `GET/POST /test-categories` - Управление категориями
- `GET/POST /questions` - Управление вопросами
- `GET/POST /answer-options` - Управление вариантами ответов
- `POST /images/upload` - Загрузка изображений
- `GET /images/{id}` - Получение изображений
## Технологии
- **Backend**: Python (http.server для статических файлов)
- **Frontend**: HTML5, CSS3, JavaScript (ES6+)
- **UI Framework**: Bootstrap 5.3
- **Icons**: Bootstrap Icons
- **API**: RESTful API с JSON
## Особенности
- Полностью адаптивный дизайн
- Валидация форм на клиентской стороне
- Уведомления об операциях
- Загрузка с индикаторами прогресса
- Подтверждение удаления
- Автоматическое обновление данных
- Поддержка изображений
- Фильтрация и поиск
## Устранение неполадок
### API сервер не запущен
```
❌ Ошибка: Cannot connect to API server
```
**Решение**: Запустите API сервер в папке `../api`
### Порт уже используется
```
❌ Ошибка: Порт 3000 уже используется
```
**Решение**: Остановите процесс на порту 3000 или измените порт в `main.py`
### Проблемы с CORS
Если возникают ошибки CORS, убедитесь, что в конфигурации API разрешены запросы с `http://localhost:3000`
## Разработка
Для изменения порта веб-сервера отредактируйте `main.py`:
```python
web_interface = WebInterface(port=3001) # Изменить порт
```
Для изменения адреса API отредактируйте `web/static/js/api.js`:
```javascript
const API_BASE_URL = 'http://localhost:8001/api/v1'; // Изменить адрес
```

View File

@@ -0,0 +1,14 @@
services:
# Nginx веб-сервер для статических файлов
web:
image: nginx:alpine
container_name: bot-web-interface
ports:
- "3000:80"
volumes:
- ./web:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
restart: unless-stopped
environment:
- NGINX_HOST=localhost
- NGINX_PORT=80

87
app.control-bot/main.py Normal file
View File

@@ -0,0 +1,87 @@
"""
Управляющее приложение для бота с веб-интерфейсом
"""
import http.server
import socketserver
import os
import sys
import webbrowser
import threading
import time
from pathlib import Path
class WebInterface:
def __init__(self, port=3000):
self.port = port
self.web_dir = Path(__file__).parent / "web"
def start_server(self):
"""Запуск веб-сервера"""
try:
# Меняем рабочую директорию на папку с веб-файлами
os.chdir(self.web_dir)
# Создаем HTTP сервер
handler = http.server.SimpleHTTPRequestHandler
with socketserver.TCPServer(("", self.port), handler) as httpd:
print(f"🚀 Веб-интерфейс управления тестами запущен!")
print(f"📡 Адрес: http://localhost:{self.port}")
print(f"📁 Директория: {self.web_dir}")
print(f"⚠️ Убедитесь, что API сервер запущен на http://localhost:8000")
print(f"💡 Для остановки нажмите Ctrl+C")
print("-" * 60)
# Открываем браузер через 2 секунды
def open_browser():
time.sleep(2)
try:
webbrowser.open(f"http://localhost:{self.port}")
except Exception as e:
print(f"Не удалось открыть браузер: {e}")
browser_thread = threading.Thread(target=open_browser)
browser_thread.daemon = True
browser_thread.start()
# Запускаем сервер
httpd.serve_forever()
except KeyboardInterrupt:
print("\n🛑 Сервер остановлен")
except OSError as e:
if e.errno == 10048: # Windows: Address already in use
print(f"❌ Ошибка: Порт {self.port} уже используется")
print(f"💡 Попробуйте другой порт или остановите процесс, использующий порт {self.port}")
else:
print(f"❌ Ошибка запуска сервера: {e}")
except Exception as e:
print(f"❌ Неожиданная ошибка: {e}")
def main():
"""Главная функция приложения"""
# Проверяем существование веб-директории
web_dir = Path(__file__).parent / "web"
if not web_dir.exists():
print("❌ Ошибка: Веб-директория не найдена!")
print(f"Ожидаемый путь: {web_dir}")
return
# Проверяем существование основных файлов
index_file = web_dir / "index.html"
if not index_file.exists():
print("❌ Ошибка: Файл index.html не найден!")
print(f"Ожидаемый путь: {index_file}")
return
print("🤖 Управляющее приложение для Telegram бота")
print("=" * 50)
# Запускаем веб-интерфейс
web_interface = WebInterface()
web_interface.start_server()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,95 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Логирование
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Основные настройки
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Сжатие
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# Основной сервер для веб-интерфейса
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Основная страница и статические файлы
location / {
try_files $uri $uri/ /index.html;
# Заголовки для работы с внешним API
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
}
# Кэширование статических файлов
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
}
# Проверка здоровья
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Безопасность - запрещаем доступ к скрытым файлам
location ~ /\. {
deny all;
}
# Отключаем логирование для служебных файлов
location = /favicon.ico {
log_not_found off;
access_log off;
}
location = /robots.txt {
log_not_found off;
access_log off;
}
# Заголовки безопасности
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'; connect-src 'self' http://localhost:8000 http://127.0.0.1:8000;" always;
}
}

View File

@@ -0,0 +1,21 @@
[project]
name = "app-control-bot"
version = "1.0.0"
description = "Веб-интерфейс для управления тестами Telegram бота"
readme = "README.md"
authors = [
{ name = "Bot Developer" }
]
requires-python = ">=3.12"
dependencies = []
[project.urls]
Repository = "https://github.com/yourusername/bot-telegram"
Issues = "https://github.com/yourusername/bot-telegram/issues"
[project.scripts]
app-control-bot = "main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View File

@@ -0,0 +1,61 @@
# PowerShell скрипт для запуска веб-интерфейса через Docker
Write-Host "🐳 Запуск веб-интерфейса управления тестами бота" -ForegroundColor Cyan
Write-Host "================================================" -ForegroundColor Cyan
# Проверяем наличие Docker
try {
docker --version | Out-Null
Write-Host "✅ Docker найден" -ForegroundColor Green
} catch {
Write-Host "❌ Docker не установлен!" -ForegroundColor Red
exit 1
}
# Проверяем наличие Docker Compose
try {
docker-compose --version | Out-Null
Write-Host "✅ Docker Compose найден" -ForegroundColor Green
} catch {
Write-Host "❌ Docker Compose не установлен!" -ForegroundColor Red
exit 1
}
# Проверяем доступность API
Write-Host "🔍 Проверка доступности API сервера..." -ForegroundColor Yellow
try {
$response = Invoke-WebRequest -Uri "http://localhost:8000/health" -TimeoutSec 3 -UseBasicParsing
Write-Host "✅ API сервер доступен" -ForegroundColor Green
} catch {
Write-Host "⚠️ API сервер недоступен на http://localhost:8000" -ForegroundColor Yellow
Write-Host "💡 Запустите API сервер в отдельном терминале:" -ForegroundColor Cyan
Write-Host " cd ../api && python run_api.py" -ForegroundColor White
Write-Host ""
$continue = Read-Host "Продолжить запуск веб-интерфейса? (y/N)"
if ($continue -notmatch "^[Yy]$") {
exit 1
}
}
# Останавливаем предыдущий контейнер если он запущен
Write-Host "🛑 Остановка предыдущих контейнеров..." -ForegroundColor Yellow
docker-compose down
# Запускаем новый контейнер
Write-Host "🚀 Запуск веб-интерфейса..." -ForegroundColor Green
docker-compose up -d
# Проверяем статус
if ($LASTEXITCODE -eq 0) {
Write-Host "Веб-интерфейс успешно запущен!" -ForegroundColor Green
Write-Host "🌐 Адрес: http://localhost:3000" -ForegroundColor Cyan
Write-Host "📋 Для просмотра логов: docker-compose logs -f web" -ForegroundColor Gray
Write-Host "🛑 Для остановки: docker-compose down" -ForegroundColor Gray
# Открываем браузер
Start-Sleep -Seconds 2
Start-Process "http://localhost:3000"
} else {
Write-Host "❌ Ошибка при запуске контейнера!" -ForegroundColor Red
Write-Host "📋 Просмотрите логи: docker-compose logs" -ForegroundColor Gray
}

View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Скрипт для запуска веб-интерфейса через Docker
echo "🐳 Запуск веб-интерфейса управления тестами бота"
echo "================================================"
# Проверяем наличие Docker
if ! command -v docker &> /dev/null; then
echo "❌ Docker не установлен!"
exit 1
fi
# Проверяем наличие Docker Compose
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose не установлен!"
exit 1
fi
# Проверяем доступность API
echo "🔍 Проверка доступности API сервера..."
if curl -s http://localhost:8000/health > /dev/null; then
echo "✅ API сервер доступен"
else
echo "⚠️ API сервер недоступен на http://localhost:8000"
echo "💡 Запустите API сервер в отдельном терминале:"
echo " cd ../api && python run_api.py"
echo ""
read -p "Продолжить запуск веб-интерфейса? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Останавливаем предыдущий контейнер если он запущен
echo "🛑 Остановка предыдущих контейнеров..."
docker-compose down
# Запускаем новый контейнер
echo "🚀 Запуск веб-интерфейса..."
docker-compose up -d
# Проверяем статус
if [ $? -eq 0 ]; then
echo "✅ Веб-интерфейс успешно запущен!"
echo "🌐 Адрес: http://localhost:3000"
echo "📋 Для просмотра логов: docker-compose logs -f web"
echo "🛑 Для остановки: docker-compose down"
# Попытка открыть браузер (для Linux/Mac)
if command -v xdg-open &> /dev/null; then
sleep 2
xdg-open http://localhost:3000
elif command -v open &> /dev/null; then
sleep 2
open http://localhost:3000
fi
else
echo "❌ Ошибка при запуске контейнера!"
echo "📋 Просмотрите логи: docker-compose logs"
fi

View File

@@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Управление тестами бота</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="static/css/style.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<i class="bi bi-robot"></i>
Управление тестами бота
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="#" id="nav-tests">Тесты</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" id="nav-categories">Категории</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" id="nav-questions">Вопросы</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid mt-3">
<!-- Уведомления -->
<div id="notifications"></div>
<!-- Панель управления тестами -->
<div id="tests-panel" class="content-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="bi bi-list-check"></i> Управление тестами</h2>
<button class="btn btn-success" id="add-test-btn">
<i class="bi bi-plus-circle"></i> Добавить тест
</button>
</div>
<!-- Фильтры -->
<div class="row mb-3">
<div class="col-md-4">
<select class="form-select" id="category-filter">
<option value="">Все категории</option>
</select>
</div>
<div class="col-md-4">
<select class="form-select" id="status-filter">
<option value="">Все статусы</option>
<option value="true">Активные</option>
<option value="false">Неактивные</option>
</select>
</div>
<div class="col-md-4">
<input type="text" class="form-control" id="search-input" placeholder="Поиск по названию...">
</div>
</div>
<!-- Таблица тестов -->
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Название</th>
<th>Категория</th>
<th>Статус</th>
<th>Время (мин)</th>
<th>Вопросов</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tests-table-body">
<!-- Данные загружаются через JavaScript -->
</tbody>
</table>
</div>
</div>
<!-- Панель управления категориями -->
<div id="categories-panel" class="content-panel" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="bi bi-tags"></i> Управление категориями</h2>
<button class="btn btn-success" id="add-category-btn">
<i class="bi bi-plus-circle"></i> Добавить категорию
</button>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Название</th>
<th>Описание</th>
<th>Тестов</th>
<th>Создана</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="categories-table-body">
<!-- Данные загружаются через JavaScript -->
</tbody>
</table>
</div>
</div>
<!-- Панель управления вопросами -->
<div id="questions-panel" class="content-panel" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="bi bi-question-circle"></i> Управление вопросами</h2>
<div>
<select class="form-select d-inline-block me-2" id="test-select" style="width: auto;">
<option value="">Выберите тест</option>
</select>
<button class="btn btn-success" id="add-question-btn" disabled>
<i class="bi bi-plus-circle"></i> Добавить вопрос
</button>
</div>
</div>
<div id="questions-content">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Выберите тест для просмотра и редактирования вопросов
</div>
</div>
</div>
</div>
<!-- Модальные окна -->
<!-- Модальное окно для теста -->
<div class="modal fade" id="testModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="testModalTitle">Добавить тест</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="testForm">
<input type="hidden" id="testId">
<div class="mb-3">
<label for="testTitle" class="form-label">Название теста *</label>
<input type="text" class="form-control" id="testTitle" required>
</div>
<div class="mb-3">
<label for="testDescription" class="form-label">Описание</label>
<textarea class="form-control" id="testDescription" rows="3"></textarea>
</div>
<div class="row">
<div class="col-md-6">
<label for="testCategory" class="form-label">Категория</label>
<select class="form-select" id="testCategory">
<option value="">Без категории</option>
</select>
</div>
<div class="col-md-6">
<label for="testTimeLimit" class="form-label">Время (минуты)</label>
<input type="number" class="form-control" id="testTimeLimit" min="1">
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="testIsActive" checked>
<label class="form-check-label" for="testIsActive">Активный</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="saveTestBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно для категории -->
<div class="modal fade" id="categoryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="categoryModalTitle">Добавить категорию</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="categoryForm">
<input type="hidden" id="categoryId">
<div class="mb-3">
<label for="categoryName" class="form-label">Название категории *</label>
<input type="text" class="form-control" id="categoryName" required>
</div>
<div class="mb-3">
<label for="categoryDescription" class="form-label">Описание</label>
<textarea class="form-control" id="categoryDescription" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="saveCategoryBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно для вопроса -->
<div class="modal fade" id="questionModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="questionModalTitle">Добавить вопрос</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="questionForm">
<input type="hidden" id="questionId">
<input type="hidden" id="questionTestId">
<div class="mb-3">
<label for="questionText" class="form-label">Текст вопроса *</label>
<textarea class="form-control" id="questionText" rows="3" required></textarea>
</div>
<div class="row">
<div class="col-md-4">
<label for="questionType" class="form-label">Тип вопроса</label>
<select class="form-select" id="questionType">
<option value="single_choice">Один ответ</option>
<option value="multiple_choice">Несколько ответов</option>
<option value="text_input">Текстовый ввод</option>
</select>
</div>
<div class="col-md-4">
<label for="questionOrder" class="form-label">Порядок *</label>
<input type="number" class="form-control" id="questionOrder" min="1" required>
</div>
<div class="col-md-4">
<label for="questionPoints" class="form-label">Баллы</label>
<input type="number" class="form-control" id="questionPoints" min="0" value="1">
</div>
</div>
<div class="mb-3">
<label for="questionImage" class="form-label">Изображение</label>
<input type="file" class="form-control" id="questionImage" accept="image/*">
<div id="currentImage" style="display: none;" class="mt-2">
<img id="imagePreview" src="" alt="Текущее изображение" style="max-width: 200px;">
<button type="button" class="btn btn-sm btn-danger ms-2" id="removeImageBtn">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
</div>
<div id="answerOptions">
<h6>Варианты ответов</h6>
<div id="optionsList">
<!-- Варианты ответов добавляются динамически -->
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="addOptionBtn">
<i class="bi bi-plus"></i> Добавить вариант
</button>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="saveQuestionBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно подтверждения удаления -->
<div class="modal fade" id="confirmDeleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Подтверждение удаления</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="deleteConfirmText">Вы уверены, что хотите удалить этот элемент?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">Удалить</button>
</div>
</div>
</div>
</div>
<!-- Загрузка -->
<div id="loadingSpinner" class="text-center" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="static/js/api.js"></script>
<script src="static/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,288 @@
/* Основные стили */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
}
/* Навигация */
.navbar-brand {
font-weight: bold;
}
/* Контент панели */
.content-panel {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Таблицы */
.table {
margin-bottom: 0;
}
.table th {
border-top: none;
font-weight: 600;
font-size: 0.9rem;
}
.table td {
vertical-align: middle;
font-size: 0.9rem;
}
/* Бейджи статуса */
.badge.bg-success {
background-color: #28a745 !important;
}
.badge.bg-secondary {
background-color: #6c757d !important;
}
/* Кнопки действий */
.btn-group .btn {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
}
/* Модальные окна */
.modal-content {
border: none;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.modal-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
/* Формы */
.form-label {
font-weight: 600;
color: #495057;
}
.form-control:focus,
.form-select:focus {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* Уведомления */
.alert {
border: none;
border-radius: 8px;
}
.alert-success {
background-color: #d4edda;
color: #155724;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
}
/* Спиннер загрузки */
#loadingSpinner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
}
/* Варианты ответов */
.answer-option {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
}
.answer-option .form-check-input {
margin-top: 0.125rem;
}
.option-controls {
display: flex;
align-items: center;
gap: 10px;
}
.option-text {
flex: 1;
}
/* Вопросы */
.question-item {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
transition: all 0.2s ease;
}
.question-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.question-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.question-text {
font-weight: 500;
color: #212529;
margin-bottom: 10px;
}
.question-meta {
font-size: 0.85rem;
color: #6c757d;
}
.question-options {
margin-top: 10px;
}
.option-item {
padding: 5px 0;
border-bottom: 1px solid #f0f0f0;
}
.option-item:last-child {
border-bottom: none;
}
.correct-option {
color: #28a745;
font-weight: 500;
}
.incorrect-option {
color: #6c757d;
}
/* Изображения */
.question-image {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 10px 0;
}
/* Адаптивность */
@media (max-width: 768px) {
.container-fluid {
padding: 10px;
}
.content-panel {
padding: 15px;
}
.table-responsive {
font-size: 0.8rem;
}
.btn-group .btn {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
}
.modal-dialog {
margin: 10px;
}
}
/* Анимации */
.fade-in {
opacity: 0;
animation: fadeIn 0.3s ease-in-out forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
/* Состояния загрузки */
.loading {
opacity: 0.6;
pointer-events: none;
}
/* Выделение активного элемента навигации */
.nav-link.active {
font-weight: 600;
}
/* Пустые состояния */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #6c757d;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 20px;
color: #dee2e6;
}
/* Кастомные утилиты */
.text-muted {
color: #6c757d !important;
}
.cursor-pointer {
cursor: pointer;
}
/* Улучшения для форм */
.is-invalid {
border-color: #dc3545;
}
.invalid-feedback {
display: block;
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Drag and drop для изображений */
.image-drop-zone {
border: 2px dashed #dee2e6;
border-radius: 4px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
}
.image-drop-zone:hover {
border-color: #0d6efd;
background-color: #f8f9ff;
}
.image-drop-zone.dragover {
border-color: #0d6efd;
background-color: #e3f2fd;
}

View 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;

View 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;