add algam
This commit is contained in:
97
app.control-bot/DOCKER.md
Normal file
97
app.control-bot/DOCKER.md
Normal 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
159
app.control-bot/README.md
Normal 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'; // Изменить адрес
|
||||
```
|
||||
14
app.control-bot/docker-compose.yml
Normal file
14
app.control-bot/docker-compose.yml
Normal 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
87
app.control-bot/main.py
Normal 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()
|
||||
95
app.control-bot/nginx.conf
Normal file
95
app.control-bot/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
21
app.control-bot/pyproject.toml
Normal file
21
app.control-bot/pyproject.toml
Normal 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"
|
||||
61
app.control-bot/start-docker.ps1
Normal file
61
app.control-bot/start-docker.ps1
Normal 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
|
||||
}
|
||||
62
app.control-bot/start-docker.sh
Normal file
62
app.control-bot/start-docker.sh
Normal 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
|
||||
315
app.control-bot/web/index.html
Normal file
315
app.control-bot/web/index.html
Normal 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>
|
||||
288
app.control-bot/web/static/css/style.css
Normal file
288
app.control-bot/web/static/css/style.css
Normal 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;
|
||||
}
|
||||
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