Add Ticket and add Role Support
This commit is contained in:
120
CHANGELOG.md
Normal file
120
CHANGELOG.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# 📝 История изменений MC Panel
|
||||||
|
|
||||||
|
## Версия 2.0 - Система тикетов (14.01.2026)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
#### 🎫 Система тикетов
|
||||||
|
- Полноценная система поддержки с чатом
|
||||||
|
- Три статуса: На рассмотрении, В работе, Закрыт
|
||||||
|
- Автоматическое обновление сообщений каждые 3 секунды
|
||||||
|
- Системные сообщения о смене статуса
|
||||||
|
- Кнопка "Тикеты" в header
|
||||||
|
|
||||||
|
#### 👥 Новая роль "Тех. поддержка"
|
||||||
|
- Доступ ко всем тикетам
|
||||||
|
- Возможность менять статусы тикетов
|
||||||
|
- Возможность отвечать на тикеты
|
||||||
|
- Отдельный бейдж в интерфейсе
|
||||||
|
|
||||||
|
#### 🔧 Улучшения управления пользователями
|
||||||
|
- Выпадающий список для выбора роли
|
||||||
|
- Три роли: Пользователь, Тех. поддержка, Администратор
|
||||||
|
- Цветные индикаторы ролей
|
||||||
|
- Описание прав для каждой роли
|
||||||
|
|
||||||
|
### 🔐 Безопасность
|
||||||
|
- Изменён логин администратора: `Sofa12345`
|
||||||
|
- Изменён пароль администратора: `arkonsad123`
|
||||||
|
|
||||||
|
### 📁 Новые файлы
|
||||||
|
- `backend/tickets.json` - хранилище тикетов
|
||||||
|
- `frontend/src/components/Tickets.jsx` - список тикетов
|
||||||
|
- `frontend/src/components/TicketChat.jsx` - чат тикета
|
||||||
|
- `frontend/src/components/CreateTicketModal.jsx` - создание тикета
|
||||||
|
- `TICKETS_SYSTEM.md` - документация системы тикетов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Версия 1.5 - Система тем (14.01.2026)
|
||||||
|
|
||||||
|
### 🎨 Темы
|
||||||
|
- 5 тем: Тёмная, Светлая, Фиолетовая, Синяя, Зелёная
|
||||||
|
- Градиентный логотип "MC Panel" для каждой темы
|
||||||
|
- Селектор тем в header
|
||||||
|
- Автоматическое сохранение выбранной темы
|
||||||
|
|
||||||
|
### 🎯 Дизайн
|
||||||
|
- Современный интерфейс в стиле TimeWeb Cloud
|
||||||
|
- Карточки с тенями и анимациями
|
||||||
|
- Плавные переходы между темами
|
||||||
|
- Адаптивный дизайн для мобильных
|
||||||
|
|
||||||
|
### 📁 Файлы
|
||||||
|
- `frontend/src/themes.js` - конфигурация тем
|
||||||
|
- `frontend/src/components/ThemeSelector.jsx` - селектор тем
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Версия 1.0 - Базовая панель (13.01.2026)
|
||||||
|
|
||||||
|
### 🖥️ Управление серверами
|
||||||
|
- Создание и удаление серверов
|
||||||
|
- Запуск и остановка серверов
|
||||||
|
- Просмотр консоли в реальном времени
|
||||||
|
- Менеджер файлов с редактированием
|
||||||
|
- Мониторинг ресурсов (RAM, диск)
|
||||||
|
- Настройки сервера
|
||||||
|
|
||||||
|
### 👥 Система пользователей
|
||||||
|
- Регистрация и авторизация
|
||||||
|
- JWT токены
|
||||||
|
- Роли: Админ и Пользователь
|
||||||
|
- Управление доступом к серверам
|
||||||
|
- Владельцы серверов
|
||||||
|
|
||||||
|
### 🌐 Сетевой доступ
|
||||||
|
- Работа через Radmin VPN
|
||||||
|
- Автоматическое определение API URL
|
||||||
|
- Поддержка локальной и сетевой работы
|
||||||
|
|
||||||
|
### 📁 Основные файлы
|
||||||
|
- `backend/main.py` - FastAPI бэкенд
|
||||||
|
- `frontend/src/App.jsx` - React фронтенд
|
||||||
|
- `backend/users.json` - хранилище пользователей
|
||||||
|
- `backend/servers/` - папка с серверами
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Планы на будущее
|
||||||
|
|
||||||
|
### Версия 2.1
|
||||||
|
- [ ] Уведомления о новых сообщениях в тикетах
|
||||||
|
- [ ] Прикрепление файлов к тикетам
|
||||||
|
- [ ] Фильтрация тикетов по статусу
|
||||||
|
- [ ] Поиск по тикетам
|
||||||
|
|
||||||
|
### Версия 2.2
|
||||||
|
- [ ] Статистика по тикетам
|
||||||
|
- [ ] Экспорт истории тикетов
|
||||||
|
- [ ] Шаблоны ответов для тех. поддержки
|
||||||
|
- [ ] Приоритеты тикетов
|
||||||
|
|
||||||
|
### Версия 3.0
|
||||||
|
- [ ] Плагины для серверов
|
||||||
|
- [ ] Автоматическое резервное копирование
|
||||||
|
- [ ] Планировщик задач
|
||||||
|
- [ ] Мониторинг производительности
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Поддержка
|
||||||
|
|
||||||
|
Если у вас возникли вопросы или проблемы:
|
||||||
|
1. Создайте тикет в системе поддержки
|
||||||
|
2. Опишите проблему подробно
|
||||||
|
3. Дождитесь ответа от тех. поддержки
|
||||||
|
|
||||||
|
**Учётные данные администратора:**
|
||||||
|
- Логин: `Sofa12345`
|
||||||
|
- Пароль: `arkonsad123`
|
||||||
143
TICKETS_SYSTEM.md
Normal file
143
TICKETS_SYSTEM.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# 🎫 Система тикетов
|
||||||
|
|
||||||
|
## Что добавлено
|
||||||
|
|
||||||
|
### ✅ Новые возможности
|
||||||
|
|
||||||
|
1. **Система тикетов** - полноценная система поддержки с чатом
|
||||||
|
2. **Три статуса тикетов**:
|
||||||
|
- 🟡 **На рассмотрении** (pending) - новый тикет
|
||||||
|
- 🔵 **В работе** (in_progress) - тикет взят в работу
|
||||||
|
- 🟢 **Закрыт** (closed) - тикет решён
|
||||||
|
|
||||||
|
3. **Новая роль "Тех. поддержка"** (support):
|
||||||
|
- Доступ ко всем тикетам
|
||||||
|
- Возможность менять статусы тикетов
|
||||||
|
- Возможность отвечать на тикеты
|
||||||
|
|
||||||
|
4. **Кнопка "Тикеты"** в header рядом с кнопкой "Пользователи"
|
||||||
|
|
||||||
|
### 📋 Возможности по ролям
|
||||||
|
|
||||||
|
#### Обычные пользователи (user)
|
||||||
|
- ✅ Создавать тикеты
|
||||||
|
- ✅ Просматривать свои тикеты
|
||||||
|
- ✅ Отправлять сообщения в свои тикеты
|
||||||
|
- ❌ Менять статусы тикетов
|
||||||
|
- ❌ Видеть чужие тикеты
|
||||||
|
|
||||||
|
#### Тех. поддержка (support)
|
||||||
|
- ✅ Просматривать все тикеты
|
||||||
|
- ✅ Отвечать на любые тикеты
|
||||||
|
- ✅ Менять статусы тикетов
|
||||||
|
- ✅ Закрывать тикеты
|
||||||
|
- ❌ Управлять пользователями
|
||||||
|
- ❌ Управлять серверами
|
||||||
|
|
||||||
|
#### Администраторы (admin)
|
||||||
|
- ✅ Все возможности тех. поддержки
|
||||||
|
- ✅ Управление пользователями
|
||||||
|
- ✅ Управление серверами
|
||||||
|
- ✅ Назначение ролей
|
||||||
|
|
||||||
|
## 🚀 Как использовать
|
||||||
|
|
||||||
|
### Создание тикета
|
||||||
|
1. Нажмите кнопку "Тикеты" в header
|
||||||
|
2. Нажмите "Создать тикет"
|
||||||
|
3. Заполните тему и описание проблемы
|
||||||
|
4. Нажмите "Создать"
|
||||||
|
|
||||||
|
### Работа с тикетом
|
||||||
|
1. Откройте список тикетов
|
||||||
|
2. Нажмите на нужный тикет
|
||||||
|
3. Пишите сообщения в чат
|
||||||
|
4. Тех. поддержка и админы могут менять статус тикета
|
||||||
|
|
||||||
|
### Назначение роли "Тех. поддержка"
|
||||||
|
1. Войдите как администратор (none / none)
|
||||||
|
2. Нажмите кнопку "Пользователи"
|
||||||
|
3. Найдите нужного пользователя
|
||||||
|
4. В выпадающем списке выберите "Тех. поддержка"
|
||||||
|
5. Роль изменится автоматически
|
||||||
|
|
||||||
|
## 📁 Новые файлы
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `backend/tickets.json` - хранилище тикетов (создаётся автоматически)
|
||||||
|
- Добавлены endpoints в `backend/main.py`:
|
||||||
|
- `GET /api/tickets` - список тикетов
|
||||||
|
- `POST /api/tickets/create` - создать тикет
|
||||||
|
- `GET /api/tickets/{id}` - получить тикет
|
||||||
|
- `POST /api/tickets/{id}/message` - добавить сообщение
|
||||||
|
- `PUT /api/tickets/{id}/status` - изменить статус
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `frontend/src/components/Tickets.jsx` - список тикетов
|
||||||
|
- `frontend/src/components/TicketChat.jsx` - чат тикета
|
||||||
|
- `frontend/src/components/CreateTicketModal.jsx` - создание тикета
|
||||||
|
|
||||||
|
## 🎨 Интерфейс
|
||||||
|
|
||||||
|
### Список тикетов
|
||||||
|
- Карточки с информацией о тикете
|
||||||
|
- Цветные индикаторы статуса
|
||||||
|
- Количество сообщений
|
||||||
|
- Дата создания
|
||||||
|
- Автор тикета
|
||||||
|
|
||||||
|
### Чат тикета
|
||||||
|
- Сообщения в реальном времени (обновление каждые 3 секунды)
|
||||||
|
- Системные сообщения о смене статуса
|
||||||
|
- Кнопки смены статуса (для тех. поддержки и админов)
|
||||||
|
- Отправка сообщений (если тикет не закрыт)
|
||||||
|
|
||||||
|
## 🔧 Технические детали
|
||||||
|
|
||||||
|
### Статусы тикетов
|
||||||
|
```javascript
|
||||||
|
pending // На рассмотрении (жёлтый)
|
||||||
|
in_progress // В работе (синий)
|
||||||
|
closed // Закрыт (зелёный)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Роли пользователей
|
||||||
|
```javascript
|
||||||
|
user // Обычный пользователь
|
||||||
|
support // Тех. поддержка
|
||||||
|
admin // Администратор
|
||||||
|
```
|
||||||
|
|
||||||
|
### Структура тикета
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"title": "Проблема с сервером",
|
||||||
|
"description": "Описание проблемы",
|
||||||
|
"author": "username",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2024-01-14T12:00:00",
|
||||||
|
"updated_at": "2024-01-14T12:00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"author": "username",
|
||||||
|
"text": "Текст сообщения",
|
||||||
|
"timestamp": "2024-01-14T12:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Готово!
|
||||||
|
|
||||||
|
Система тикетов полностью интегрирована в MC Panel. Пользователи могут создавать тикеты, а тех. поддержка и администраторы могут на них отвечать и управлять статусами.
|
||||||
|
|
||||||
|
### Учётные данные по умолчанию
|
||||||
|
- **Логин**: Sofa12345
|
||||||
|
- **Пароль**: arkonsad123
|
||||||
|
- **Роль**: admin
|
||||||
|
|
||||||
|
Для создания пользователя тех. поддержки:
|
||||||
|
1. Зарегистрируйте нового пользователя
|
||||||
|
2. Войдите как админ
|
||||||
|
3. Назначьте ему роль "Тех. поддержка"
|
||||||
147
backend/main.py
147
backend/main.py
@@ -36,6 +36,7 @@ security = HTTPBearer(auto_error=False)
|
|||||||
SERVERS_DIR = Path("servers")
|
SERVERS_DIR = Path("servers")
|
||||||
SERVERS_DIR.mkdir(exist_ok=True)
|
SERVERS_DIR.mkdir(exist_ok=True)
|
||||||
USERS_FILE = Path("users.json")
|
USERS_FILE = Path("users.json")
|
||||||
|
TICKETS_FILE = Path("tickets.json")
|
||||||
|
|
||||||
server_processes: dict[str, subprocess.Popen] = {}
|
server_processes: dict[str, subprocess.Popen] = {}
|
||||||
server_logs: dict[str, list[str]] = {}
|
server_logs: dict[str, list[str]] = {}
|
||||||
@@ -46,13 +47,13 @@ IS_WINDOWS = sys.platform == 'win32'
|
|||||||
def init_users():
|
def init_users():
|
||||||
if not USERS_FILE.exists():
|
if not USERS_FILE.exists():
|
||||||
admin_user = {
|
admin_user = {
|
||||||
"username": "admin",
|
"username": "Sofa12345",
|
||||||
"password": pwd_context.hash("admin"),
|
"password": pwd_context.hash("arkonsad123"),
|
||||||
"role": "admin",
|
"role": "admin",
|
||||||
"servers": []
|
"servers": []
|
||||||
}
|
}
|
||||||
save_users({"admin": admin_user})
|
save_users({"Sofa12345": admin_user})
|
||||||
print("Создан пользователь по умолчанию: admin / admin")
|
print("Создан пользователь по умолчанию: none / none")
|
||||||
|
|
||||||
def load_users() -> dict:
|
def load_users() -> dict:
|
||||||
if USERS_FILE.exists():
|
if USERS_FILE.exists():
|
||||||
@@ -80,6 +81,17 @@ def save_server_config(server_name: str, config: dict):
|
|||||||
with open(config_path, 'w', encoding='utf-8') as f:
|
with open(config_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Функции для работы с тикетами
|
||||||
|
def load_tickets() -> dict:
|
||||||
|
if TICKETS_FILE.exists():
|
||||||
|
with open(TICKETS_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_tickets(tickets: dict):
|
||||||
|
with open(TICKETS_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(tickets, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
init_users()
|
init_users()
|
||||||
|
|
||||||
# Функции аутентификации
|
# Функции аутентификации
|
||||||
@@ -752,6 +764,133 @@ async def rename_file(server_name: str, old_path: str, new_name: str, user: dict
|
|||||||
old_file_path.rename(new_file_path)
|
old_file_path.rename(new_file_path)
|
||||||
return {"message": "Файл переименован"}
|
return {"message": "Файл переименован"}
|
||||||
|
|
||||||
|
# API для тикетов
|
||||||
|
@app.get("/api/tickets")
|
||||||
|
async def get_tickets(user: dict = Depends(get_current_user)):
|
||||||
|
"""Получить список тикетов"""
|
||||||
|
tickets = load_tickets()
|
||||||
|
|
||||||
|
# Админы и тех. поддержка видят все тикеты
|
||||||
|
if user["role"] in ["admin", "support"]:
|
||||||
|
return list(tickets.values())
|
||||||
|
|
||||||
|
# Обычные пользователи видят только свои тикеты
|
||||||
|
user_tickets = [t for t in tickets.values() if t["author"] == user["username"]]
|
||||||
|
return user_tickets
|
||||||
|
|
||||||
|
@app.post("/api/tickets/create")
|
||||||
|
async def create_ticket(data: dict, user: dict = Depends(get_current_user)):
|
||||||
|
"""Создать новый тикет"""
|
||||||
|
tickets = load_tickets()
|
||||||
|
|
||||||
|
# Генерируем ID тикета
|
||||||
|
ticket_id = str(len(tickets) + 1)
|
||||||
|
|
||||||
|
ticket = {
|
||||||
|
"id": ticket_id,
|
||||||
|
"title": data.get("title", "").strip(),
|
||||||
|
"description": data.get("description", "").strip(),
|
||||||
|
"author": user["username"],
|
||||||
|
"status": "pending", # pending, in_progress, closed
|
||||||
|
"created_at": datetime.utcnow().isoformat(),
|
||||||
|
"updated_at": datetime.utcnow().isoformat(),
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"author": user["username"],
|
||||||
|
"text": data.get("description", "").strip(),
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
tickets[ticket_id] = ticket
|
||||||
|
save_tickets(tickets)
|
||||||
|
|
||||||
|
return {"message": "Тикет создан", "ticket": ticket}
|
||||||
|
|
||||||
|
@app.get("/api/tickets/{ticket_id}")
|
||||||
|
async def get_ticket(ticket_id: str, user: dict = Depends(get_current_user)):
|
||||||
|
"""Получить тикет по ID"""
|
||||||
|
tickets = load_tickets()
|
||||||
|
|
||||||
|
if ticket_id not in tickets:
|
||||||
|
raise HTTPException(404, "Тикет не найден")
|
||||||
|
|
||||||
|
ticket = tickets[ticket_id]
|
||||||
|
|
||||||
|
# Проверка доступа
|
||||||
|
if user["role"] not in ["admin", "support"] and ticket["author"] != user["username"]:
|
||||||
|
raise HTTPException(403, "Нет доступа к этому тикету")
|
||||||
|
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
@app.post("/api/tickets/{ticket_id}/message")
|
||||||
|
async def add_ticket_message(ticket_id: str, data: dict, user: dict = Depends(get_current_user)):
|
||||||
|
"""Добавить сообщение в тикет"""
|
||||||
|
tickets = load_tickets()
|
||||||
|
|
||||||
|
if ticket_id not in tickets:
|
||||||
|
raise HTTPException(404, "Тикет не найден")
|
||||||
|
|
||||||
|
ticket = tickets[ticket_id]
|
||||||
|
|
||||||
|
# Проверка доступа
|
||||||
|
if user["role"] not in ["admin", "support"] and ticket["author"] != user["username"]:
|
||||||
|
raise HTTPException(403, "Нет доступа к этому тикету")
|
||||||
|
|
||||||
|
message = {
|
||||||
|
"author": user["username"],
|
||||||
|
"text": data.get("text", "").strip(),
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket["messages"].append(message)
|
||||||
|
ticket["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
tickets[ticket_id] = ticket
|
||||||
|
save_tickets(tickets)
|
||||||
|
|
||||||
|
return {"message": "Сообщение добавлено", "ticket": ticket}
|
||||||
|
|
||||||
|
@app.put("/api/tickets/{ticket_id}/status")
|
||||||
|
async def update_ticket_status(ticket_id: str, data: dict, user: dict = Depends(get_current_user)):
|
||||||
|
"""Изменить статус тикета (только для админов и тех. поддержки)"""
|
||||||
|
if user["role"] not in ["admin", "support"]:
|
||||||
|
raise HTTPException(403, "Недостаточно прав")
|
||||||
|
|
||||||
|
tickets = load_tickets()
|
||||||
|
|
||||||
|
if ticket_id not in tickets:
|
||||||
|
raise HTTPException(404, "Тикет не найден")
|
||||||
|
|
||||||
|
new_status = data.get("status")
|
||||||
|
if new_status not in ["pending", "in_progress", "closed"]:
|
||||||
|
raise HTTPException(400, "Неверный статус")
|
||||||
|
|
||||||
|
ticket = tickets[ticket_id]
|
||||||
|
ticket["status"] = new_status
|
||||||
|
ticket["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
# Добавляем системное сообщение о смене статуса
|
||||||
|
status_names = {
|
||||||
|
"pending": "На рассмотрении",
|
||||||
|
"in_progress": "В работе",
|
||||||
|
"closed": "Закрыт"
|
||||||
|
}
|
||||||
|
|
||||||
|
message = {
|
||||||
|
"author": "system",
|
||||||
|
"text": f"Статус изменён на: {status_names[new_status]}",
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket["messages"].append(message)
|
||||||
|
|
||||||
|
tickets[ticket_id] = ticket
|
||||||
|
save_tickets(tickets)
|
||||||
|
|
||||||
|
return {"message": "Статус обновлён", "ticket": ticket}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
|||||||
48
backend/tickets.json
Normal file
48
backend/tickets.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"id": "1",
|
||||||
|
"title": "Пошёл нахуй",
|
||||||
|
"description": "Свин",
|
||||||
|
"author": "arkonsad",
|
||||||
|
"status": "closed",
|
||||||
|
"created_at": "2026-01-14T15:20:26.344010",
|
||||||
|
"updated_at": "2026-01-14T15:22:02.654579",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"author": "arkonsad",
|
||||||
|
"text": "Свин",
|
||||||
|
"timestamp": "2026-01-14T15:20:26.344010"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"author": "Sofa12345",
|
||||||
|
"text": "Ты че",
|
||||||
|
"timestamp": "2026-01-14T15:21:19.943424"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"author": "Sofa12345",
|
||||||
|
"text": "ахуел",
|
||||||
|
"timestamp": "2026-01-14T15:21:24.251787"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"author": "arkonsad",
|
||||||
|
"text": "покушай говна",
|
||||||
|
"timestamp": "2026-01-14T15:21:46.676746"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"author": "system",
|
||||||
|
"text": "Статус изменён на: В работе",
|
||||||
|
"timestamp": "2026-01-14T15:21:48.504108"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"author": "Sofa12345",
|
||||||
|
"text": "тварина ты ебаная",
|
||||||
|
"timestamp": "2026-01-14T15:21:58.245227"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"author": "system",
|
||||||
|
"text": "Статус изменён на: Закрыт",
|
||||||
|
"timestamp": "2026-01-14T15:22:02.654579"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
{
|
{
|
||||||
"admin": {
|
|
||||||
"username": "admin",
|
|
||||||
"password": "$2b$12$0AJU/Cc6vI.gqUY6BfU8E.6adiK3QS/1EyZJ98MAExiHAf4HOhn4C",
|
|
||||||
"role": "admin",
|
|
||||||
"servers": []
|
|
||||||
},
|
|
||||||
"MihailPrud": {
|
"MihailPrud": {
|
||||||
"username": "MihailPrud",
|
"username": "MihailPrud",
|
||||||
"password": "$2b$12$GfbQN4scE.b.mtUHofWWE.Dn1tQpT1zwLAxeICv90sHP4zGv0dc2G",
|
"password": "$2b$12$GfbQN4scE.b.mtUHofWWE.Dn1tQpT1zwLAxeICv90sHP4zGv0dc2G",
|
||||||
@@ -13,5 +7,19 @@
|
|||||||
"test",
|
"test",
|
||||||
"nya"
|
"nya"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"arkonsad": {
|
||||||
|
"username": "arkonsad",
|
||||||
|
"password": "$2b$12$z.AYkfa/MlTYFd9rLNfBmu9JHOFKUe8YdddnqCmRqAxc7vGQeo392",
|
||||||
|
"role": "user",
|
||||||
|
"servers": [
|
||||||
|
"123"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Sofa12345": {
|
||||||
|
"username": "Sofa12345",
|
||||||
|
"password": "$2b$12$Fph20p2mwgOAqoT77wSA3.n1S7NiHLa28aiNOwWcz3PfNhgC5pp5.",
|
||||||
|
"role": "admin",
|
||||||
|
"servers": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Server, Play, Square, Terminal, FolderOpen, HardDrive, Settings, Plus, Users as UsersIcon, LogOut, Menu, X } from 'lucide-react';
|
import { Server, Play, Square, Terminal, FolderOpen, HardDrive, Settings, Plus, Users as UsersIcon, LogOut, Menu, X, MessageSquare } from 'lucide-react';
|
||||||
import Console from './components/Console';
|
import Console from './components/Console';
|
||||||
import FileManager from './components/FileManager';
|
import FileManager from './components/FileManager';
|
||||||
import Stats from './components/Stats';
|
import Stats from './components/Stats';
|
||||||
import ServerSettings from './components/ServerSettings';
|
import ServerSettings from './components/ServerSettings';
|
||||||
import CreateServerModal from './components/CreateServerModal';
|
import CreateServerModal from './components/CreateServerModal';
|
||||||
import Users from './components/Users';
|
import Users from './components/Users';
|
||||||
|
import Tickets from './components/Tickets';
|
||||||
import Auth from './components/Auth';
|
import Auth from './components/Auth';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import ThemeSelector from './components/ThemeSelector';
|
import ThemeSelector from './components/ThemeSelector';
|
||||||
@@ -21,6 +22,7 @@ function App() {
|
|||||||
const [activeTab, setActiveTab] = useState('console');
|
const [activeTab, setActiveTab] = useState('console');
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showUsers, setShowUsers] = useState(false);
|
const [showUsers, setShowUsers] = useState(false);
|
||||||
|
const [showTickets, setShowTickets] = useState(false);
|
||||||
const [connectionError, setConnectionError] = useState(false);
|
const [connectionError, setConnectionError] = useState(false);
|
||||||
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark');
|
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
@@ -156,7 +158,7 @@ function App() {
|
|||||||
{user?.username}
|
{user?.username}
|
||||||
</span>
|
</span>
|
||||||
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
|
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
|
||||||
{user?.role === 'admin' ? 'Админ' : 'Пользователь'}
|
{user?.role === 'admin' ? 'Админ' : user?.role === 'support' ? 'Поддержка' : 'Пользователь'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
|
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
|
||||||
@@ -183,6 +185,56 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTickets) {
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen ${currentTheme.primary} ${currentTheme.text} transition-colors duration-300`}>
|
||||||
|
<header className={`${currentTheme.secondary} ${currentTheme.border} border-b backdrop-blur-sm bg-opacity-95 sticky top-0 z-40`}>
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`${currentTheme.accent} p-2 rounded-lg`}>
|
||||||
|
<Server className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className={`text-xl font-bold bg-gradient-to-r ${currentTheme.gradient} bg-clip-text text-transparent`}>MC Panel</h1>
|
||||||
|
<p className={`text-xs ${currentTheme.textSecondary}`}>Управление серверами</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`px-3 py-1.5 rounded-lg ${currentTheme.card} ${currentTheme.border} border`}>
|
||||||
|
<span className={`text-sm ${currentTheme.textSecondary}`}>
|
||||||
|
{user?.username}
|
||||||
|
</span>
|
||||||
|
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
|
||||||
|
{user?.role === 'admin' ? 'Админ' : user?.role === 'support' ? 'Поддержка' : 'Пользователь'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTickets(false)}
|
||||||
|
className={`${currentTheme.card} ${currentTheme.hover} px-4 py-2 rounded-lg transition flex items-center gap-2`}
|
||||||
|
>
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
Серверы
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`${currentTheme.danger} hover:opacity-90 px-4 py-2 rounded-lg transition flex items-center gap-2 text-white`}
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<Tickets token={token} user={user} theme={currentTheme} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen ${currentTheme.primary} ${currentTheme.text} transition-colors duration-300`}>
|
<div className={`min-h-screen ${currentTheme.primary} ${currentTheme.text} transition-colors duration-300`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -217,10 +269,17 @@ function App() {
|
|||||||
{user?.username}
|
{user?.username}
|
||||||
</span>
|
</span>
|
||||||
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
|
<span className={`ml-2 text-xs px-2 py-0.5 rounded ${currentTheme.accent} text-white`}>
|
||||||
{user?.role === 'admin' ? 'Админ' : 'Пользователь'}
|
{user?.role === 'admin' ? 'Админ' : user?.role === 'support' ? 'Поддержка' : 'Пользователь'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
|
<ThemeSelector currentTheme={theme} onThemeChange={handleThemeChange} />
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTickets(true)}
|
||||||
|
className={`${currentTheme.accent} ${currentTheme.accentHover} px-4 py-2 rounded-lg transition flex items-center gap-2 text-white`}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Тикеты</span>
|
||||||
|
</button>
|
||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUsers(true)}
|
onClick={() => setShowUsers(true)}
|
||||||
|
|||||||
@@ -50,13 +50,7 @@ export default function Users({ token }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRole = async (username, currentRole) => {
|
const changeRole = async (username, newRole) => {
|
||||||
const newRole = currentRole === 'admin' ? 'user' : 'admin';
|
|
||||||
|
|
||||||
if (!confirm(`Изменить роль пользователя ${username} на ${newRole}?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.put(
|
await axios.put(
|
||||||
`${API_URL}/api/users/${username}/role`,
|
`${API_URL}/api/users/${username}/role`,
|
||||||
@@ -108,7 +102,7 @@ export default function Users({ token }) {
|
|||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`p-2 rounded ${
|
<div className={`p-2 rounded ${
|
||||||
user.role === 'admin' ? 'bg-blue-600' : 'bg-gray-700'
|
user.role === 'admin' ? 'bg-blue-600' : user.role === 'support' ? 'bg-purple-600' : 'bg-gray-700'
|
||||||
}`}>
|
}`}>
|
||||||
{user.role === 'admin' ? (
|
{user.role === 'admin' ? (
|
||||||
<Shield className="w-6 h-6" />
|
<Shield className="w-6 h-6" />
|
||||||
@@ -119,18 +113,21 @@ export default function Users({ token }) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">{user.username}</h3>
|
<h3 className="text-lg font-semibold">{user.username}</h3>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
{user.role === 'admin' ? 'Администратор' : user.role === 'support' ? 'Тех. поддержка' : 'Пользователь'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<select
|
||||||
onClick={() => toggleRole(user.username, user.role)}
|
value={user.role}
|
||||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm"
|
onChange={(e) => changeRole(user.username, e.target.value)}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded text-sm border border-gray-600 focus:outline-none focus:border-blue-500"
|
||||||
>
|
>
|
||||||
{user.role === 'admin' ? 'Сделать пользователем' : 'Сделать админом'}
|
<option value="user">Пользователь</option>
|
||||||
</button>
|
<option value="support">Тех. поддержка</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteUser(user.username)}
|
onClick={() => deleteUser(user.username)}
|
||||||
className="bg-red-600 hover:bg-red-700 p-2 rounded"
|
className="bg-red-600 hover:bg-red-700 p-2 rounded"
|
||||||
@@ -141,7 +138,7 @@ export default function Users({ token }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user.role !== 'admin' && (
|
{user.role === 'user' && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-2 text-gray-400">
|
<h4 className="text-sm font-medium mb-2 text-gray-400">
|
||||||
Доступ к серверам:
|
Доступ к серверам:
|
||||||
@@ -175,6 +172,12 @@ export default function Users({ token }) {
|
|||||||
Администратор имеет доступ ко всем серверам
|
Администратор имеет доступ ко всем серверам
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{user.role === 'support' && (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Тех. поддержка имеет доступ к системе тикетов
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ http://localhost:3000
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4️⃣ Войдите в систему
|
### 4️⃣ Войдите в систему
|
||||||
- **Логин**: `admin`
|
- **Логин**: `Sofa12345`
|
||||||
- **Пароль**: `admin`
|
- **Пароль**: `arkonsad123`
|
||||||
|
|
||||||
## 🎨 Смена темы
|
## 🎨 Смена темы
|
||||||
|
|
||||||
@@ -57,6 +57,16 @@ http://localhost:3000
|
|||||||
1. Нажмите кнопку "Пользователи" в header
|
1. Нажмите кнопку "Пользователи" в header
|
||||||
2. Создайте новых пользователей
|
2. Создайте новых пользователей
|
||||||
3. Выдайте доступ к серверам
|
3. Выдайте доступ к серверам
|
||||||
|
4. Назначьте роли (Пользователь, Тех. поддержка, Администратор)
|
||||||
|
|
||||||
|
### Система тикетов 🎫
|
||||||
|
1. Нажмите кнопку "Тикеты" в header
|
||||||
|
2. Создайте тикет с описанием проблемы
|
||||||
|
3. Общайтесь в чате тикета
|
||||||
|
4. Тех. поддержка и админы могут менять статусы:
|
||||||
|
- 🟡 На рассмотрении
|
||||||
|
- 🔵 В работе
|
||||||
|
- 🟢 Закрыт
|
||||||
|
|
||||||
### Выдача доступа к серверу
|
### Выдача доступа к серверу
|
||||||
1. Выберите сервер
|
1. Выберите сервер
|
||||||
|
|||||||
166
ГОТОВО.md
Normal file
166
ГОТОВО.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# ✅ MC Panel готова к использованию!
|
||||||
|
|
||||||
|
## 🎉 Что сделано
|
||||||
|
|
||||||
|
### 1. Изменён логин администратора
|
||||||
|
- **Старый**: admin / admin
|
||||||
|
- **Новый**: Sofa12345 / arkonsad123
|
||||||
|
|
||||||
|
### 2. Добавлена система тикетов 🎫
|
||||||
|
- Кнопка "Тикеты" в header рядом с "Пользователи"
|
||||||
|
- Создание тикетов с темой и описанием
|
||||||
|
- Чат для общения в тикете
|
||||||
|
- Три статуса:
|
||||||
|
- 🟡 На рассмотрении
|
||||||
|
- 🔵 В работе
|
||||||
|
- 🟢 Закрыт
|
||||||
|
|
||||||
|
### 3. Добавлена роль "Тех. поддержка" 👨💻
|
||||||
|
- Доступ ко всем тикетам
|
||||||
|
- Возможность менять статусы
|
||||||
|
- Возможность отвечать на тикеты
|
||||||
|
- Отдельный бейдж в интерфейсе
|
||||||
|
|
||||||
|
### 4. Улучшено управление пользователями
|
||||||
|
- Выпадающий список для выбора роли
|
||||||
|
- Три роли: Пользователь, Тех. поддержка, Администратор
|
||||||
|
- Цветные индикаторы ролей
|
||||||
|
|
||||||
|
## 🚀 Запуск панели
|
||||||
|
|
||||||
|
### Шаг 1: Запустите бэкенд
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2: Запустите фронтенд
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Откройте в браузере
|
||||||
|
```
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 4: Войдите как администратор
|
||||||
|
- **Логин**: none
|
||||||
|
- **Пароль**: none
|
||||||
|
|
||||||
|
## 📋 Быстрый старт
|
||||||
|
|
||||||
|
### Создание пользователя тех. поддержки
|
||||||
|
1. Зарегистрируйте нового пользователя
|
||||||
|
2. Войдите как админ (Sofa12345)
|
||||||
|
3. Нажмите "Пользователи"
|
||||||
|
4. Найдите нового пользователя
|
||||||
|
5. В выпадающем списке выберите "Тех. поддержка"
|
||||||
|
|
||||||
|
### Создание тикета
|
||||||
|
1. Войдите как обычный пользователь
|
||||||
|
2. Нажмите кнопку "Тикеты" в header
|
||||||
|
3. Нажмите "Создать тикет"
|
||||||
|
4. Заполните тему и описание
|
||||||
|
5. Нажмите "Создать"
|
||||||
|
|
||||||
|
### Работа с тикетом (тех. поддержка)
|
||||||
|
1. Войдите как пользователь с ролью "Тех. поддержка"
|
||||||
|
2. Нажмите "Тикеты"
|
||||||
|
3. Выберите тикет из списка
|
||||||
|
4. Отвечайте на сообщения
|
||||||
|
5. Меняйте статус тикета кнопками вверху
|
||||||
|
|
||||||
|
## 🎨 Возможности
|
||||||
|
|
||||||
|
### Для всех пользователей
|
||||||
|
- ✅ Создание серверов
|
||||||
|
- ✅ Управление своими серверами
|
||||||
|
- ✅ Создание тикетов
|
||||||
|
- ✅ Общение в своих тикетах
|
||||||
|
- ✅ Смена темы интерфейса
|
||||||
|
|
||||||
|
### Для тех. поддержки
|
||||||
|
- ✅ Все возможности пользователя
|
||||||
|
- ✅ Просмотр всех тикетов
|
||||||
|
- ✅ Ответы на любые тикеты
|
||||||
|
- ✅ Изменение статусов тикетов
|
||||||
|
|
||||||
|
### Для администраторов
|
||||||
|
- ✅ Все возможности тех. поддержки
|
||||||
|
- ✅ Управление пользователями
|
||||||
|
- ✅ Назначение ролей
|
||||||
|
- ✅ Удаление пользователей
|
||||||
|
- ✅ Управление доступом к серверам
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
MC Panel/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # Основной файл бэкенда
|
||||||
|
│ ├── users.json # База пользователей
|
||||||
|
│ ├── tickets.json # База тикетов (создаётся автоматически)
|
||||||
|
│ └── servers/ # Папка с серверами
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── App.jsx # Главный компонент
|
||||||
|
│ ├── themes.js # Конфигурация тем
|
||||||
|
│ └── components/
|
||||||
|
│ ├── Auth.jsx # Авторизация
|
||||||
|
│ ├── Tickets.jsx # Список тикетов
|
||||||
|
│ ├── TicketChat.jsx # Чат тикета
|
||||||
|
│ ├── CreateTicketModal.jsx # Создание тикета
|
||||||
|
│ ├── Users.jsx # Управление пользователями
|
||||||
|
│ ├── Console.jsx # Консоль сервера
|
||||||
|
│ ├── FileManager.jsx # Менеджер файлов
|
||||||
|
│ ├── Stats.jsx # Статистика
|
||||||
|
│ └── ServerSettings.jsx # Настройки сервера
|
||||||
|
│
|
||||||
|
└── Документация/
|
||||||
|
├── ГОТОВО.md # Этот файл
|
||||||
|
├── TICKETS_SYSTEM.md # Документация системы тикетов
|
||||||
|
├── CHANGELOG.md # История изменений
|
||||||
|
└── БЫСТРЫЙ_СТАРТ.md # Быстрый старт
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Что дальше?
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
1. Создайте несколько пользователей
|
||||||
|
2. Назначьте одному роль "Тех. поддержка"
|
||||||
|
3. Создайте тикет от имени обычного пользователя
|
||||||
|
4. Ответьте на тикет от имени тех. поддержки
|
||||||
|
5. Измените статус тикета
|
||||||
|
|
||||||
|
### Настройка
|
||||||
|
1. Измените темы под свой вкус в `frontend/src/themes.js`
|
||||||
|
2. Настройте порты в конфигурации
|
||||||
|
3. Добавьте свои серверы
|
||||||
|
|
||||||
|
### Развёртывание
|
||||||
|
1. Настройте production сборку фронтенда
|
||||||
|
2. Настройте HTTPS для безопасности
|
||||||
|
3. Настройте базу данных вместо JSON файлов
|
||||||
|
4. Настройте резервное копирование
|
||||||
|
|
||||||
|
## 📞 Поддержка
|
||||||
|
|
||||||
|
Если возникли вопросы:
|
||||||
|
1. Прочитайте `TICKETS_SYSTEM.md`
|
||||||
|
2. Прочитайте `CHANGELOG.md`
|
||||||
|
3. Создайте тикет в системе
|
||||||
|
|
||||||
|
## ✨ Готово!
|
||||||
|
|
||||||
|
Панель MC Panel полностью готова к использованию со всеми функциями:
|
||||||
|
- ✅ Управление серверами
|
||||||
|
- ✅ Система пользователей
|
||||||
|
- ✅ Система тикетов
|
||||||
|
- ✅ Роль тех. поддержки
|
||||||
|
- ✅ 5 тем оформления
|
||||||
|
- ✅ Современный интерфейс
|
||||||
|
|
||||||
|
**Наслаждайтесь использованием! 🚀**
|
||||||
Reference in New Issue
Block a user