/
evgeny.kumpyakov
/
restman
Обзор
Документация
Войти
/
evgeny.kumpyakov
/
restman
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
main.py
1 760 строк
79 KB
EvgenyKumpyakov
Добавлены лицензии и общее описание проекта
24 июн 2026, 14:44
24 июн 2026, 14:44
90478e3
Код
Авторство
О чём код?
#!/usr/bin/env python3 """ RESTman - Приложение для тестирования REST API эндпоинтов Copyright (C) 2026 EvgenyKumpyakov This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. """ import sys import os from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTreeWidget, QTreeWidgetItem, QTextEdit, QPushButton, QLabel, QLineEdit, QComboBox, QGroupBox, QFormLayout, QInputDialog, QMessageBox, QMenu, QFileDialog, QSizePolicy, QDialog, QTabWidget, QCheckBox, QTableWidget, QTableWidgetItem, QHeaderView ) from PyQt6.QtCore import Qt, QTimer, QSettings from PyQt6.QtGui import QFont, QAction, QIcon import requests import json import re from datetime import datetime from urllib.parse import quote, urlencode from database import Database class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("RESTman - REST API Tester") self.setGeometry(100, 100, 1200, 800) # Инициализируем QSettings для сохранения настроек self.settings = QSettings("RESTman", "RESTman") # Путь к текущему файлу БД self.current_db_path = None # Флаг для отслеживания первого показа окна self.first_show = True # Текущий выбранный запрос (для сохранения данных) self.current_request_item = None # Флаг для предотвращения автоматического добавления заголовков при загрузке данных self.is_loading_request = False # Текущая тема (по умолчанию светлая) self.current_theme = "light" # Создаем меню self.create_menu() # Загружаем сохраненную тему self.load_theme() # Центральный виджет central_widget = QWidget() self.setCentralWidget(central_widget) # Главный горизонтальный layout main_layout = QHBoxLayout(central_widget) main_layout.setContentsMargins(5, 5, 5, 5) main_layout.setSpacing(5) # Создаем сплиттер для разделения на три части (горизонтально) splitter = QSplitter(Qt.Orientation.Horizontal) main_layout.addWidget(splitter) # Первая часть: Группы запросов self.requests_panel = self.create_requests_panel() splitter.addWidget(self.requests_panel) # Вторая часть: Детали запроса и кнопка Send self.request_details_panel = self.create_request_details_panel() splitter.addWidget(self.request_details_panel) # Третья часть: Ответ сервера self.response_panel = self.create_response_panel() splitter.addWidget(self.response_panel) # Вычисляем минимальную ширину (10% от ширины окна) window_width = self.width() min_width = int(window_width * 0.1) # Устанавливаем минимальную ширину для каждой панели self.requests_panel.setMinimumWidth(min_width) self.request_details_panel.setMinimumWidth(min_width) self.response_panel.setMinimumWidth(min_width) # Устанавливаем размеры для трех панелей: 20%, 40%, 40% first_panel_width = int(window_width * 0.2) second_panel_width = int(window_width * 0.4) third_panel_width = int(window_width * 0.4) splitter.setSizes([first_panel_width, second_panel_width, third_panel_width]) # Сохраняем ссылку на сплиттер для обновления минимальной ширины self.main_splitter = splitter # Не инициализируем тестовые данные - пользователь выберет БД при запуске def resizeEvent(self, event): """Обновляет минимальную ширину панелей при изменении размера окна""" super().resizeEvent(event) # Вычисляем минимальную ширину (10% от текущей ширины окна) window_width = self.width() min_width = int(window_width * 0.1) # Обновляем минимальную ширину для каждой панели if hasattr(self, 'requests_panel'): self.requests_panel.setMinimumWidth(min_width) if hasattr(self, 'request_details_panel'): self.request_details_panel.setMinimumWidth(min_width) if hasattr(self, 'response_panel'): self.response_panel.setMinimumWidth(min_width) def showEvent(self, event): """Переопределяем showEvent для показа диалога выбора БД при запуске""" super().showEvent(event) # Показываем диалог выбора БД только при первом запуске if self.first_show: self.first_show = False QTimer.singleShot(100, self.ask_open_database_on_startup) def ask_open_database_on_startup(self): """Показывает диалог с вопросом об открытии БД при запуске приложения или открывает последнюю БД""" # Проверяем, есть ли сохраненный путь к последней открытой БД last_db_path = self.settings.value("last_database_path", None) if last_db_path and os.path.exists(last_db_path): # Если есть сохраненный путь и файл существует, открываем его автоматически try: db = Database(last_db_path) groups_data = db.load_groups_and_requests() db.close() # Загружаем данные в дерево self.load_groups_data(groups_data) # Сохраняем путь к текущему файлу self.current_db_path = last_db_path # Обновляем заголовок окна self.setWindowTitle(f"RESTman - REST API Tester - {os.path.basename(last_db_path)}") except Exception as e: # Если не удалось открыть сохраненную БД, показываем диалог QMessageBox.warning( self, "Ошибка", f"Не удалось открыть последнюю базу данных:\n{str(e)}\n\nБудет показан диалог выбора." ) self.show_open_database_dialog() else: # Если нет сохраненного пути, показываем диалог с вопросом self.show_open_database_dialog() def show_open_database_dialog(self): """Показывает диалог с вопросом об открытии БД""" reply = QMessageBox.question( self, "Открыть базу данных", "Открыть базу данных?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes ) if reply == QMessageBox.StandardButton.Yes: # Пользователь выбрал "Да" - показываем диалог выбора файла БД self.open_database_dialog() else: # Пользователь выбрал "Нет" - показываем тестовые данные self.init_test_data() self.sort_groups() def open_database_dialog(self): """Показывает диалог выбора файла БД""" # Получаем путь к корню проекта project_root = os.path.dirname(os.path.abspath(__file__)) # Диалог выбора файла file_path, _ = QFileDialog.getOpenFileName( self, "Выберите базу данных для открытия", project_root, "SQLite Database (*.db *.sqlite *.sqlite3);;All Files (*)" ) if file_path: try: # Загружаем данные из БД db = Database(file_path) groups_data = db.load_groups_and_requests() db.close() # Загружаем данные в дерево self.load_groups_data(groups_data) # Сохраняем путь к текущему файлу self.current_db_path = file_path # Сохраняем путь к последней открытой БД self.settings.setValue("last_database_path", file_path) # Обновляем заголовок окна self.setWindowTitle(f"RESTman - REST API Tester - {os.path.basename(file_path)}") except Exception as e: QMessageBox.critical( self, "Ошибка", f"Не удалось открыть базу данных:\n{str(e)}" ) # Если не удалось открыть БД, показываем тестовые данные self.init_test_data() self.sort_groups() else: # Если пользователь отменил выбор файла, показываем тестовые данные self.init_test_data() self.sort_groups() def create_menu(self): """Создает меню приложения""" menubar = self.menuBar() # Меню "Файл" file_menu = menubar.addMenu("Файл") # Действие "Открыть" open_action = QAction("Открыть", self) open_action.setShortcut("Ctrl+O") open_action.triggered.connect(self.open_database) file_menu.addAction(open_action) # Действие "Сохранить" save_action = QAction("Сохранить", self) save_action.setShortcut("Ctrl+S") save_action.triggered.connect(self.save_database) file_menu.addAction(save_action) # Действие "Сохранить как..." save_as_action = QAction("Сохранить как...", self) save_as_action.setShortcut("Ctrl+Shift+S") save_as_action.triggered.connect(self.save_database_as) file_menu.addAction(save_as_action) # Разделитель file_menu.addSeparator() # Действие "Выйти" exit_action = QAction("Выйти", self) exit_action.setShortcut("Ctrl+Q") exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) # Меню "Вид" view_menu = menubar.addMenu("Вид") # Действие "Светлая тема" self.light_theme_action = QAction("Светлая тема", self) self.light_theme_action.setCheckable(True) self.light_theme_action.triggered.connect(lambda: self.set_theme("light")) view_menu.addAction(self.light_theme_action) # Действие "Темная тема" self.dark_theme_action = QAction("Темная тема", self) self.dark_theme_action.setCheckable(True) self.dark_theme_action.triggered.connect(lambda: self.set_theme("dark")) view_menu.addAction(self.dark_theme_action) def create_requests_panel(self): """Создает панель с группами запросов""" group_box = QGroupBox("Группы запросов") layout = QVBoxLayout(group_box) # Дерево запросов self.requests_tree = QTreeWidget() self.requests_tree.setHeaderLabel("Запросы") self.requests_tree.itemClicked.connect(self.on_request_selected) self.requests_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.requests_tree.customContextMenuRequested.connect(self.show_context_menu) # Убеждаемся, что дерево прокручивается при большом количестве элементов self.requests_tree.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) layout.addWidget(self.requests_tree) return group_box def create_request_details_panel(self): """Создает панель с деталями запроса""" group_box = QGroupBox("Детали запроса") layout = QVBoxLayout(group_box) # Метод и URL method_layout = QHBoxLayout() self.method_combo = QComboBox() self.method_combo.addItems(["GET", "POST", "PUT", "DELETE", "PATCH"]) self.method_combo.setFixedWidth(100) self.method_combo.currentTextChanged.connect(self.on_method_changed) self.url_input = QLineEdit() self.url_input.setPlaceholderText("https://api.example.com/endpoint") method_layout.addWidget(QLabel("Метод:")) method_layout.addWidget(self.method_combo) method_layout.addWidget(QLabel("URL:")) method_layout.addWidget(self.url_input) layout.addLayout(method_layout) # Табы для Auth и Params self.tabs = QTabWidget() self.tabs.addTab(self.create_auth_tab(), "Auth") self.tabs.addTab(self.create_params_tab(), "Params") layout.addWidget(self.tabs) # Кнопка Send self.send_button = QPushButton("Send") self.send_button.setObjectName("send_button") self.send_button.setStyleSheet(""" QPushButton { background-color: #4CAF50; color: white; font-weight: bold; padding: 10px; border-radius: 5px; } QPushButton:hover { background-color: #45a049; } QPushButton:pressed { background-color: #3d8b40; } """) self.send_button.clicked.connect(self.send_request) layout.addWidget(self.send_button) # Заголовки headers_label = QLabel("Заголовки (JSON):") layout.addWidget(headers_label) self.headers_edit = QTextEdit() self.headers_edit.setPlaceholderText('{"Content-Type": "application/json"}') self.headers_edit.setMaximumHeight(100) self.headers_edit.setStyleSheet("QTextEdit { background-color: white; color: black; }") self.headers_edit.document().setDefaultStyleSheet("body { color: black; background-color: white; }") self.headers_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) layout.addWidget(self.headers_edit) # Тело запроса body_label = QLabel("Тело запроса:") layout.addWidget(body_label) self.body_edit = QTextEdit() self.body_edit.setPlaceholderText('{"key": "value"}') self.body_edit.setStyleSheet("QTextEdit { background-color: white; color: black; }") self.body_edit.document().setDefaultStyleSheet("body { color: black; background-color: white; }") self.body_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) layout.addWidget(self.body_edit) return group_box def create_auth_tab(self): """Создает таб с настройками авторизации""" auth_widget = QWidget() auth_layout = QVBoxLayout(auth_widget) auth_layout.setContentsMargins(5, 5, 5, 5) # Селектор типа авторизации auth_type_layout = QHBoxLayout() auth_type_layout.addWidget(QLabel("Тип авторизации:")) self.auth_type_combo = QComboBox() self.auth_type_combo.addItems(["Bearer Token", "Basic"]) self.auth_type_combo.currentTextChanged.connect(self.on_auth_type_changed) auth_type_layout.addWidget(self.auth_type_combo) auth_type_layout.addStretch() auth_layout.addLayout(auth_type_layout) # Чекбокс Enabled self.auth_enabled_checkbox = QCheckBox("Enabled") auth_layout.addWidget(self.auth_enabled_checkbox) # Контейнер для виджетов Bearer Token self.bearer_widget = QWidget() bearer_layout = QVBoxLayout(self.bearer_widget) bearer_layout.setContentsMargins(0, 0, 0, 0) # Поле Token token_layout = QHBoxLayout() token_layout.addWidget(QLabel("Token:")) self.bearer_token_input = QLineEdit() self.bearer_token_input.setPlaceholderText("Введите токен") token_layout.addWidget(self.bearer_token_input) bearer_layout.addLayout(token_layout) # Поле Prefix prefix_layout = QHBoxLayout() prefix_layout.addWidget(QLabel("Prefix:")) self.bearer_prefix_input = QLineEdit() self.bearer_prefix_input.setPlaceholderText("Bearer") self.bearer_prefix_input.setText("Bearer") prefix_layout.addWidget(self.bearer_prefix_input) bearer_layout.addLayout(prefix_layout) # Контейнер для виджетов Basic self.basic_widget = QWidget() basic_layout = QVBoxLayout(self.basic_widget) basic_layout.setContentsMargins(0, 0, 0, 0) # Поле Username username_layout = QHBoxLayout() username_layout.addWidget(QLabel("Username:")) self.basic_username_input = QLineEdit() self.basic_username_input.setPlaceholderText("Введите username") username_layout.addWidget(self.basic_username_input) basic_layout.addLayout(username_layout) # Поле Password password_layout = QHBoxLayout() password_layout.addWidget(QLabel("Password:")) self.basic_password_input = QLineEdit() self.basic_password_input.setPlaceholderText("Введите password") self.basic_password_input.setEchoMode(QLineEdit.EchoMode.Password) password_layout.addWidget(self.basic_password_input) basic_layout.addLayout(password_layout) # Добавляем оба виджета в layout (переключение через setVisible) auth_layout.addWidget(self.bearer_widget) auth_layout.addWidget(self.basic_widget) # Изначально показываем Bearer Token self.basic_widget.setVisible(False) auth_layout.addStretch() return auth_widget def create_params_tab(self): """Создает таб с параметрами запроса""" params_widget = QWidget() params_layout = QVBoxLayout(params_widget) params_layout.setContentsMargins(5, 5, 5, 5) # Создаем таблицу параметров self.params_table = QTableWidget() self.params_table.setColumnCount(3) self.params_table.setHorizontalHeaderLabels(["Ключ", "Значение", "Управление"]) # Настраиваем заголовки таблицы header = self.params_table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) self.params_table.setColumnWidth(2, 100) # Устанавливаем контекстное меню для таблицы self.params_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.params_table.customContextMenuRequested.connect(self.show_params_context_menu) # Подключаем обработчик изменения данных в ячейках self.params_table.cellChanged.connect(self.on_params_cell_changed) params_layout.addWidget(self.params_table) return params_widget def on_auth_type_changed(self, auth_type): """Обработчик изменения типа авторизации""" if auth_type == "Bearer Token": self.bearer_widget.setVisible(True) self.basic_widget.setVisible(False) else: # Basic self.bearer_widget.setVisible(False) self.basic_widget.setVisible(True) def show_params_context_menu(self, position): """Показывает контекстное меню для таблицы параметров""" menu = QMenu(self) add_param_action = QAction("Добавить параметр", self) add_param_action.triggered.connect(self.add_param_row) menu.addAction(add_param_action) menu.exec(self.params_table.mapToGlobal(position)) def add_param_row(self): """Добавляет новую строку с параметром в таблицу""" row = self.params_table.rowCount() self.params_table.insertRow(row) # Создаем ячейки для ключа и значения key_item = QTableWidgetItem("") value_item = QTableWidgetItem("") self.params_table.setItem(row, 0, key_item) self.params_table.setItem(row, 1, value_item) # Создаем кнопку удаления delete_button = QPushButton("Удалить") delete_button.clicked.connect(lambda checked, r=row: self.delete_param_row(r)) self.params_table.setCellWidget(row, 2, delete_button) def delete_param_row(self, row): """Удаляет строку с параметром из таблицы""" if row < 0 or row >= self.params_table.rowCount(): return self.params_table.removeRow(row) # Обновляем индексы кнопок удаления для оставшихся строк self.update_delete_buttons() def update_delete_buttons(self): """Обновляет индексы кнопок удаления после удаления строки""" for row in range(self.params_table.rowCount()): delete_button = self.params_table.cellWidget(row, 2) if delete_button: # Отключаем все старые сигналы try: delete_button.clicked.disconnect() except TypeError: # Если сигналов нет, игнорируем ошибку pass # Подключаем новый сигнал с правильным индексом delete_button.clicked.connect(lambda checked, r=row: self.delete_param_row(r)) def on_params_cell_changed(self, row, column): """Обработчик изменения ячейки в таблице параметров""" # Автоматически сохраняем данные при изменении параметров if self.current_request_item and self.current_request_item.parent() is not None: # Не сохраняем сразу, чтобы не было лишних обновлений pass def get_params_dict(self): """Получает словарь параметров из таблицы""" params = {} for row in range(self.params_table.rowCount()): key_item = self.params_table.item(row, 0) value_item = self.params_table.item(row, 1) if key_item and value_item: key = key_item.text().strip() value = value_item.text().strip() # Добавляем параметр только если заполнены и ключ, и значение if key and value: params[key] = value return params def load_params_to_table(self, params_dict): """Загружает параметры из словаря в таблицу""" # Очищаем таблицу self.params_table.setRowCount(0) # Загружаем параметры for key, value in params_dict.items(): row = self.params_table.rowCount() self.params_table.insertRow(row) key_item = QTableWidgetItem(key) value_item = QTableWidgetItem(value) self.params_table.setItem(row, 0, key_item) self.params_table.setItem(row, 1, value_item) # Создаем кнопку удаления delete_button = QPushButton("Удалить") delete_button.clicked.connect(lambda checked, r=row: self.delete_param_row(r)) self.params_table.setCellWidget(row, 2, delete_button) def create_response_panel(self): """Создает панель с ответом сервера""" group_box = QGroupBox("Ответ сервера") layout = QVBoxLayout(group_box) # Время ответа self.response_time_label = QLabel("Время ответа: -") self.response_time_label.setStyleSheet("font-weight: bold; color: #2196F3;") layout.addWidget(self.response_time_label) # Статус код self.status_label = QLabel("Статус: -") layout.addWidget(self.status_label) # Ответ response_label = QLabel("Ответ:") layout.addWidget(response_label) self.response_edit = QTextEdit() self.response_edit.setReadOnly(True) self.response_edit.setFont(QFont("Courier", 10)) self.response_edit.setStyleSheet("QTextEdit { background-color: white; color: black; }") self.response_edit.document().setDefaultStyleSheet("body { color: black; background-color: white; }") self.response_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) layout.addWidget(self.response_edit) return group_box def init_test_data(self): """Инициализирует тестовые данные для демонстрации""" # Группа 1: Пользователи users_group = QTreeWidgetItem(self.requests_tree, ["Пользователи"]) users_group.setExpanded(True) get_users = QTreeWidgetItem(users_group, ["GET /users"]) get_users.setData(0, Qt.ItemDataRole.UserRole, { "method": "GET", "url": "https://jsonplaceholder.typicode.com/users", "headers": "{}", "body": "", "params": "{}", "auth_type": "Bearer Token", "auth_enabled": False, "bearer_token": "", "bearer_prefix": "Bearer", "basic_username": "", "basic_password": "" }) get_user = QTreeWidgetItem(users_group, ["GET /users/1"]) get_user.setData(0, Qt.ItemDataRole.UserRole, { "method": "GET", "url": "https://jsonplaceholder.typicode.com/users/1", "headers": "{}", "body": "", "params": "{}", "auth_type": "Bearer Token", "auth_enabled": False, "bearer_token": "", "bearer_prefix": "Bearer", "basic_username": "", "basic_password": "" }) # Группа 2: Посты posts_group = QTreeWidgetItem(self.requests_tree, ["Посты"]) posts_group.setExpanded(True) get_posts = QTreeWidgetItem(posts_group, ["GET /posts"]) get_posts.setData(0, Qt.ItemDataRole.UserRole, { "method": "GET", "url": "https://jsonplaceholder.typicode.com/posts", "headers": '{"Content-Type": "application/json"}', "body": "", "params": "{}", "auth_type": "Bearer Token", "auth_enabled": False, "bearer_token": "", "bearer_prefix": "Bearer", "basic_username": "", "basic_password": "" }) create_post = QTreeWidgetItem(posts_group, ["POST /posts"]) create_post.setData(0, Qt.ItemDataRole.UserRole, { "method": "POST", "url": "https://jsonplaceholder.typicode.com/posts", "headers": '{"Content-Type": "application/json"}', "body": '{\n "title": "Test Post",\n "body": "This is a test post",\n "userId": 1\n}', "params": "{}", "auth_type": "Bearer Token", "auth_enabled": False, "bearer_token": "", "bearer_prefix": "Bearer", "basic_username": "", "basic_password": "" }) def on_request_selected(self, item, column): """Обработчик выбора запроса из дерева""" # Сохраняем данные предыдущего запроса перед переключением if self.current_request_item and self.current_request_item.parent() is not None: self.save_current_request_data() self.current_request_item = item # Проверяем, является ли элемент запросом (имеет родителя) if item.parent() is None: # Это группа, не запрос - очищаем поля self.clear_request_details() return # Устанавливаем флаг загрузки, чтобы не срабатывал on_method_changed self.is_loading_request = True data = item.data(0, Qt.ItemDataRole.UserRole) if data: # Устанавливаем метод method_index = self.method_combo.findText(data.get("method", "GET")) if method_index >= 0: self.method_combo.setCurrentIndex(method_index) else: self.method_combo.setCurrentIndex(0) # По умолчанию GET # Устанавливаем URL self.url_input.setText(data.get("url", "")) # Устанавливаем заголовки self._set_text(self.headers_edit, data.get("headers", "{}")) # Устанавливаем тело запроса self._set_text(self.body_edit, data.get("body", "")) # Устанавливаем настройки авторизации # Поддержка обратной совместимости auth_type = data.get("auth_type") if not auth_type or auth_type == "": # Если есть старые данные username/password, используем Basic if data.get("username") or data.get("password"): auth_type = "Basic" else: auth_type = "Bearer Token" # Преобразуем auth_enabled в bool (может быть int из БД) auth_enabled = data.get("auth_enabled", False) if isinstance(auth_enabled, int): auth_enabled = bool(auth_enabled) # Устанавливаем тип авторизации auth_index = self.auth_type_combo.findText(auth_type) if auth_index >= 0: self.auth_type_combo.setCurrentIndex(auth_index) else: self.auth_type_combo.setCurrentIndex(0) # По умолчанию Bearer Token auth_type = "Bearer Token" # Устанавливаем чекбокс Enabled self.auth_enabled_checkbox.setChecked(auth_enabled) # Устанавливаем значения в зависимости от типа if auth_type == "Bearer Token": bearer_token = data.get("bearer_token", "") or "" bearer_prefix = data.get("bearer_prefix", "Bearer") or "Bearer" self.bearer_token_input.setText(bearer_token) self.bearer_prefix_input.setText(bearer_prefix) # Очищаем поля Basic при переключении на Bearer self.basic_username_input.clear() self.basic_password_input.clear() elif auth_type == "Basic": basic_username = data.get("basic_username", "") or data.get("username", "") or "" basic_password = data.get("basic_password", "") or data.get("password", "") or "" self.basic_username_input.setText(basic_username) self.basic_password_input.setText(basic_password) # Очищаем поля Bearer при переключении на Basic self.bearer_token_input.clear() self.bearer_prefix_input.setText("Bearer") # Убеждаемся, что правильные виджеты видны self.on_auth_type_changed(auth_type) # Загружаем параметры запроса params_str = data.get("params", "{}") try: params_dict = json.loads(params_str) if params_str else {} except json.JSONDecodeError: params_dict = {} self.load_params_to_table(params_dict) else: # Если данных нет, очищаем поля (новый запрос) self.clear_request_details() # Снимаем флаг загрузки self.is_loading_request = False def _set_text(self, text_edit, text): """Устанавливает текст в QTextEdit, сбрасывая накопленное форматирование""" from PyQt6.QtGui import QTextCharFormat, QColor text_edit.clear() fmt = QTextCharFormat() fmt.setForeground(QColor("black")) cursor = text_edit.textCursor() cursor.setCharFormat(fmt) text_edit.setTextCursor(cursor) text_edit.setPlainText(text) def clear_request_details(self): """Очищает поля деталей запроса""" self.method_combo.setCurrentIndex(0) # GET по умолчанию self.url_input.clear() self._set_text(self.headers_edit, "{}") self._set_text(self.body_edit, "") # Очищаем настройки авторизации self.auth_type_combo.setCurrentIndex(0) # Bearer Token по умолчанию self.auth_enabled_checkbox.setChecked(False) self.bearer_token_input.clear() self.bearer_prefix_input.setText("Bearer") self.basic_username_input.clear() self.basic_password_input.clear() # Очищаем параметры self.params_table.setRowCount(0) def on_method_changed(self, method): """Обработчик изменения метода запроса""" # Не добавляем заголовки при загрузке данных запроса if self.is_loading_request: return # При переключении на POST, если тело пустое, добавляем Content-Type if method == "POST" and not self.body_edit.toPlainText().strip(): headers_text = self.headers_edit.toPlainText().strip() try: headers = json.loads(headers_text) if headers_text else {} # Добавляем Content-Type только если его еще нет if "Content-Type" not in headers: headers["Content-Type"] = "application/json" self._set_text(self.headers_edit, json.dumps(headers, indent=2, ensure_ascii=False)) except json.JSONDecodeError: # Если заголовки невалидны, устанавливаем только Content-Type self._set_text(self.headers_edit, '{"Content-Type": "application/json"}') def append_query_params_to_url(self, url): """Добавляет query-параметры из таблицы Params к URL""" params = self.get_params_dict() if not params: return url query_parts = [] for key, value in params.items(): encoded_key = quote(str(key)) encoded_value = quote(str(value)) query_parts.append(f"{encoded_key}={encoded_value}") query_string = "&".join(query_parts) if query_string: separator = "&" if "?" in url else "?" return f"{url}{separator}{query_string}" return url def send_request(self): """Отправляет HTTP запрос""" url = self.url_input.text().strip() if not url: self.response_edit.setPlainText("Ошибка: URL не указан") return method = self.method_combo.currentText() # Парсим заголовки headers = {} headers_text = self.headers_edit.toPlainText().strip() if headers_text: try: headers = json.loads(headers_text) except json.JSONDecodeError: self.response_edit.setPlainText("Ошибка: Неверный формат заголовков (должен быть JSON)") return # Парсим тело запроса body_text = self.body_edit.toPlainText().strip() body = None if body_text: try: body = json.loads(body_text) except json.JSONDecodeError: # Если не JSON, отправляем как строку body = body_text # Добавляем query-параметры из вкладки Params к URL url = self.append_query_params_to_url(url) # Настраиваем авторизацию auth = None # Проверяем, включена ли авторизация if self.auth_enabled_checkbox.isChecked(): auth_type = self.auth_type_combo.currentText() if auth_type == "Bearer Token": # Bearer Token авторизация token = self.bearer_token_input.text().strip() prefix = self.bearer_prefix_input.text().strip() if token: # Если префикс пустой, используем "Bearer" по умолчанию if not prefix: prefix = "Bearer" # Добавляем заголовок Authorization headers["Authorization"] = f"{prefix} {token}" elif auth_type == "Basic": # Basic Authentication username = self.basic_username_input.text().strip() password = self.basic_password_input.text().strip() if username and password: auth = requests.auth.HTTPBasicAuth(username, password) # Засекаем время start_time = datetime.now() try: # Отправляем запрос if method == "GET": response = requests.get(url, headers=headers, auth=auth, timeout=10) elif method == "POST": response = requests.post(url, json=body, headers=headers, auth=auth, timeout=10) elif method == "PUT": response = requests.put(url, json=body, headers=headers, auth=auth, timeout=10) elif method == "DELETE": response = requests.delete(url, headers=headers, auth=auth, timeout=10) elif method == "PATCH": response = requests.patch(url, json=body, headers=headers, auth=auth, timeout=10) else: self.response_edit.setPlainText(f"Ошибка: Неподдерживаемый метод {method}") return # Вычисляем время ответа end_time = datetime.now() response_time = (end_time - start_time).total_seconds() * 1000 # в миллисекундах # Обновляем время ответа self.response_time_label.setText(f"Время ответа: {response_time:.2f} мс") # Обновляем статус status_color = "#4CAF50" if 200 <= response.status_code < 300 else "#f44336" self.status_label.setText(f'Статус: <span style="color: {status_color}; font-weight: bold;">{response.status_code} {response.reason}</span>') # Форматируем ответ try: response_json = response.json() formatted_response = json.dumps(response_json, indent=2, ensure_ascii=False) except: formatted_response = response.text # Добавляем заголовки ответа response_headers = "\n".join([f"{k}: {v}" for k, v in response.headers.items()]) full_response = f"Заголовки ответа:\n{response_headers}\n\nТело ответа:\n{formatted_response}" self.response_edit.setPlainText(full_response) except requests.exceptions.Timeout: self.response_time_label.setText("Время ответа: -") self.status_label.setText('Статус: <span style="color: #f44336; font-weight: bold;">Timeout</span>') self.response_edit.setPlainText("Ошибка: Превышено время ожидания ответа") except requests.exceptions.ConnectionError: self.response_time_label.setText("Время ответа: -") self.status_label.setText('Статус: <span style="color: #f44336; font-weight: bold;">Connection Error</span>') self.response_edit.setPlainText("Ошибка: Не удалось подключиться к серверу") except Exception as e: self.response_time_label.setText("Время ответа: -") self.status_label.setText(f'Статус: <span style="color: #f44336; font-weight: bold;">Error</span>') self.response_edit.setPlainText(f"Ошибка: {str(e)}") def show_context_menu(self, position): """Показывает контекстное меню для дерева запросов""" item = self.requests_tree.itemAt(position) menu = QMenu(self) if item is None: # Клик по пустой области или корню дерева add_group_action = QAction("Добавить группу запросов", self) add_group_action.triggered.connect(self.add_request_group) menu.addAction(add_group_action) else: # Проверяем, является ли элемент группой (имеет ли родителя - корень дерева) parent = item.parent() if parent is None: # Это группа запросов create_request_action = QAction("Создать запрос", self) create_request_action.triggered.connect(lambda: self.create_request(item)) menu.addAction(create_request_action) menu.addSeparator() edit_action = QAction("Редактировать название", self) edit_action.triggered.connect(lambda: self.edit_request_group(item)) menu.addAction(edit_action) delete_action = QAction("Удалить группу запросов", self) delete_action.triggered.connect(lambda: self.delete_request_group(item)) menu.addAction(delete_action) else: # Это запрос (не группа) edit_name_action = QAction("Редактировать название запроса", self) edit_name_action.triggered.connect(lambda: self.edit_request_name(item)) menu.addAction(edit_name_action) duplicate_action = QAction("Дублировать", self) duplicate_action.triggered.connect(lambda: self.duplicate_request(item)) menu.addAction(duplicate_action) menu.addSeparator() delete_request_action = QAction("Удалить запрос", self) delete_request_action.triggered.connect(lambda: self.delete_request(item)) menu.addAction(delete_request_action) menu.exec(self.requests_tree.mapToGlobal(position)) def get_text_input(self, title, label, text=""): """Вспомогательный метод для получения текста с диалогом нужной ширины""" dialog = QInputDialog(self) dialog.setWindowTitle(title) dialog.setLabelText(label) dialog.setTextValue(text) dialog.setInputMode(QInputDialog.InputMode.TextInput) # Вычисляем ширину диалога: 40% от ширины окна, но не менее 500px dialog_width = max(int(self.width() * 0.4), 500) # Получаем текущую высоту и устанавливаем новую ширину current_height = dialog.height() dialog.resize(dialog_width, current_height) if dialog.exec() == QDialog.DialogCode.Accepted: return dialog.textValue(), True return "", False def add_request_group(self): """Добавляет новую группу запросов""" name, ok = self.get_text_input( "Добавить группу запросов", "Введите название группы:", text="" ) if ok and name.strip(): group = QTreeWidgetItem(self.requests_tree, [name.strip()]) group.setExpanded(True) self.sort_groups() def edit_request_group(self, item): """Редактирует название группы запросов""" if item.parent() is not None: return # Это не группа, а запрос current_name = item.text(0) name, ok = self.get_text_input( "Редактировать название группы", "Введите новое название группы:", text=current_name ) if ok and name.strip(): item.setText(0, name.strip()) self.sort_groups() def delete_request_group(self, item): """Удаляет группу запросов""" if item.parent() is not None: return # Это не группа, а запрос group_name = item.text(0) reply = QMessageBox.question( self, "Подтверждение удаления", f"Вы уверены, что хотите удалить группу '{group_name}'?\n\nВсе запросы в этой группе также будут удалены.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: # Удаляем группу верхнего уровня из корня дерева index = self.requests_tree.indexOfTopLevelItem(item) if index >= 0: self.requests_tree.takeTopLevelItem(index) # Очищаем текущий выбранный запрос, если он был в удаленной группе if self.current_request_item and self.current_request_item.parent() == item: self.current_request_item = None self.clear_request_details() def create_request(self, group_item): """Создает новый запрос в группе""" if group_item.parent() is not None: return # Это не группа, а запрос name, ok = self.get_text_input( "Создать запрос", "Введите название запроса:", text="" ) if ok and name.strip(): # Создаем новый запрос с пустыми данными request_item = QTreeWidgetItem(group_item, [name.strip()]) request_item.setData(0, Qt.ItemDataRole.UserRole, { "method": "GET", "url": "", "headers": "{}", "body": "", "params": "{}", "auth_type": "Bearer Token", "auth_enabled": False, "bearer_token": "", "bearer_prefix": "Bearer", "basic_username": "", "basic_password": "" }) # Выбираем созданный запрос self.requests_tree.setCurrentItem(request_item) self.on_request_selected(request_item, 0) # Очищаем поля деталей запроса self.clear_request_details() def edit_request_name(self, item): """Редактирует название запроса""" if item.parent() is None: return # Это группа, а не запрос current_name = item.text(0) name, ok = self.get_text_input( "Редактировать название запроса", "Введите новое название запроса:", text=current_name ) if ok and name.strip(): if item == self.current_request_item: self.save_current_request_data() item.setText(0, name.strip()) def duplicate_request(self, item): """Дублирует запрос в той же группе с добавлением порядкового номера""" if item.parent() is None: return # Это группа, а не запрос # Получаем родительскую группу parent_group = item.parent() # Получаем данные исходного запроса original_data = item.data(0, Qt.ItemDataRole.UserRole) if not original_data: original_data = { "method": "GET", "url": "", "headers": "{}", "body": "", "auth_type": "Bearer Token", "auth_enabled": False, "bearer_token": "", "bearer_prefix": "Bearer", "basic_username": "", "basic_password": "" } # Получаем исходное имя запроса original_name = item.text(0) # Находим базовое имя (без номера дубликата в скобках) base_name_match = re.match(r'^(.+?)(\s*\(\d+\))?$', original_name) if base_name_match: base_name = base_name_match.group(1) else: base_name = original_name # Находим все запросы в группе с таким же базовым именем existing_numbers = [] child_count = parent_group.childCount() for i in range(child_count): child = parent_group.child(i) child_name = child.text(0) # Проверяем, начинается ли имя с базового имени if child_name.startswith(base_name): # Пытаемся извлечь номер из скобок match = re.search(r'\((\d+)\)$', child_name) if match: existing_numbers.append(int(match.group(1))) elif child_name == base_name: # Это оригинальный запрос без номера, считаем его как (0) existing_numbers.append(0) # Находим следующий доступный номер if existing_numbers: next_number = max(existing_numbers) + 1 else: # Если нет запросов с таким именем, начинаем с (1) next_number = 1 # Формируем имя дубликата duplicate_name = f"{base_name} ({next_number})" # Создаем новый запрос-дубликат duplicate_item = QTreeWidgetItem(parent_group, [duplicate_name]) # Поддержка обратной совместимости auth_type = original_data.get("auth_type", "Bearer Token") if not auth_type and (original_data.get("username") or original_data.get("password")): auth_type = "Basic" duplicate_item.setData(0, Qt.ItemDataRole.UserRole, { "method": original_data.get("method", "GET"), "url": original_data.get("url", ""), "headers": original_data.get("headers", "{}"), "body": original_data.get("body", ""), "params": original_data.get("params", "{}"), "auth_type": auth_type, "auth_enabled": original_data.get("auth_enabled", False), "bearer_token": original_data.get("bearer_token", ""), "bearer_prefix": original_data.get("bearer_prefix", "Bearer"), "basic_username": original_data.get("basic_username", "") or original_data.get("username", ""), "basic_password": original_data.get("basic_password", "") or original_data.get("password", "") }) # Выбираем созданный дубликат self.requests_tree.setCurrentItem(duplicate_item) self.on_request_selected(duplicate_item, 0) def delete_request(self, item): """Удаляет запрос""" if item.parent() is None: return # Это группа, а не запрос request_name = item.text(0) reply = QMessageBox.question( self, "Подтверждение удаления", f"Вы уверены, что хотите удалить запрос '{request_name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: # Удаляем запрос из группы parent = item.parent() if parent: parent.removeChild(item) # Очищаем текущий выбранный запрос, если он был удален if self.current_request_item == item: self.current_request_item = None self.clear_request_details() def sort_groups(self): """Сортирует группы запросов по алфавиту""" # Извлекаем все группы верхнего уровня (чтобы они не были удалены при clear) groups = [] count = self.requests_tree.topLevelItemCount() for i in range(count): item = self.requests_tree.takeTopLevelItem(0) # Всегда берем первый элемент groups.append(item) # Сортируем по названию groups.sort(key=lambda x: x.text(0).lower()) # Добавляем отсортированные группы обратно for group in groups: self.requests_tree.addTopLevelItem(group) def get_groups_data(self): """ Извлекает данные групп и запросов из дерева Returns: Список словарей с данными групп и запросов """ groups_data = [] count = self.requests_tree.topLevelItemCount() for i in range(count): group_item = self.requests_tree.topLevelItem(i) group_name = group_item.text(0) requests_data = [] child_count = group_item.childCount() for j in range(child_count): request_item = group_item.child(j) request_name = request_item.text(0) request_data = request_item.data(0, Qt.ItemDataRole.UserRole) if request_data: # Поддержка обратной совместимости со старыми данными auth_type = request_data.get('auth_type', 'Bearer Token') if not auth_type and (request_data.get('username') or request_data.get('password')): # Миграция старых данных Basic auth auth_type = 'Basic' # Преобразуем auth_enabled в bool (может быть int из БД или bool из памяти) auth_enabled = request_data.get('auth_enabled', False) if isinstance(auth_enabled, int): auth_enabled = bool(auth_enabled) requests_data.append({ 'name': request_name, 'method': request_data.get('method', 'GET'), 'url': request_data.get('url', ''), 'headers': request_data.get('headers', '{}'), 'body': request_data.get('body', ''), 'params': request_data.get('params', '{}'), 'auth_type': auth_type, 'auth_enabled': auth_enabled, 'bearer_token': request_data.get('bearer_token', '') or '', 'bearer_prefix': request_data.get('bearer_prefix', 'Bearer') or 'Bearer', 'basic_username': request_data.get('basic_username', '') or request_data.get('username', '') or '', 'basic_password': request_data.get('basic_password', '') or request_data.get('password', '') or '' }) groups_data.append({ 'name': group_name, 'requests': requests_data }) return groups_data def load_groups_data(self, groups_data): """ Загружает данные групп и запросов в дерево Args: groups_data: Список словарей с данными групп и запросов """ # Очищаем дерево self.requests_tree.clear() # Загружаем группы и запросы for group_data in groups_data: group_item = QTreeWidgetItem(self.requests_tree, [group_data['name']]) group_item.setExpanded(True) for request_data in group_data.get('requests', []): # Для миграции: если название пустое или отсутствует, используем "Новый запрос" request_name = request_data.get('name', '').strip() if not request_name: request_name = "Новый запрос" request_item = QTreeWidgetItem(group_item, [request_name]) # Поддержка обратной совместимости auth_type = request_data.get('auth_type') if not auth_type or auth_type == '': if request_data.get('username') or request_data.get('password'): auth_type = 'Basic' else: auth_type = 'Bearer Token' # Преобразуем auth_enabled в bool auth_enabled = request_data.get('auth_enabled', False) if isinstance(auth_enabled, int): auth_enabled = bool(auth_enabled) request_item.setData(0, Qt.ItemDataRole.UserRole, { 'method': request_data.get('method', 'GET'), 'url': request_data.get('url', ''), 'headers': request_data.get('headers', '{}'), 'body': request_data.get('body', ''), 'params': request_data.get('params', '{}'), 'auth_type': auth_type, 'auth_enabled': auth_enabled, 'bearer_token': request_data.get('bearer_token', '') or '', 'bearer_prefix': request_data.get('bearer_prefix', 'Bearer') or 'Bearer', 'basic_username': request_data.get('basic_username', '') or request_data.get('username', '') or '', 'basic_password': request_data.get('basic_password', '') or request_data.get('password', '') or '' }) # Сортируем группы self.sort_groups() def open_database(self): """Открывает файл базы данных и загружает данные""" # Получаем путь к корню проекта project_root = os.path.dirname(os.path.abspath(__file__)) # Диалог выбора файла file_path, _ = QFileDialog.getOpenFileName( self, "Открыть базу данных", project_root, "SQLite Database (*.db *.sqlite *.sqlite3);;All Files (*)" ) if file_path: try: # Загружаем данные из БД db = Database(file_path) groups_data = db.load_groups_and_requests() db.close() # Загружаем данные в дерево self.load_groups_data(groups_data) # Сохраняем путь к текущему файлу self.current_db_path = file_path # Сохраняем путь к последней открытой БД self.settings.setValue("last_database_path", file_path) # Обновляем заголовок окна self.setWindowTitle(f"RESTman - REST API Tester - {os.path.basename(file_path)}") QMessageBox.information( self, "Успех", f"База данных успешно загружена из файла:\n{file_path}" ) except Exception as e: QMessageBox.critical( self, "Ошибка", f"Не удалось открыть базу данных:\n{str(e)}" ) def save_database(self): """Сохраняет данные в файл базы данных""" # Сохраняем данные текущего выбранного запроса перед сохранением в БД if self.current_request_item and self.current_request_item.parent() is not None: self.save_current_request_data() # Получаем путь к корню проекта project_root = os.path.dirname(os.path.abspath(__file__)) # Если файл уже открыт, используем его путь, иначе показываем диалог сохранения if self.current_db_path: file_path = self.current_db_path else: file_path, _ = QFileDialog.getSaveFileName( self, "Сохранить базу данных", os.path.join(project_root, "restman.db"), "SQLite Database (*.db *.sqlite *.sqlite3);;All Files (*)" ) if file_path: try: # Извлекаем данные из дерева groups_data = self.get_groups_data() # Сохраняем в БД db = Database(file_path) success = db.save_groups_and_requests(groups_data) db.close() if success: # Сохраняем путь к текущему файлу self.current_db_path = file_path # Обновляем заголовок окна self.setWindowTitle(f"RESTman - REST API Tester - {os.path.basename(file_path)}") QMessageBox.information( self, "Успех", f"Данные успешно сохранены в файл:\n{file_path}" ) else: QMessageBox.critical( self, "Ошибка", "Не удалось сохранить данные в базу данных" ) except Exception as e: QMessageBox.critical( self, "Ошибка", f"Не удалось сохранить базу данных:\n{str(e)}" ) def save_current_request_data(self): """Сохраняет данные текущего выбранного запроса в дерево""" if not self.current_request_item or self.current_request_item.parent() is None: return # Получаем текущие данные из полей формы method = self.method_combo.currentText() url = self.url_input.text() headers = self.headers_edit.toPlainText() body = self.body_edit.toPlainText() # Получаем данные авторизации auth_type = self.auth_type_combo.currentText() auth_enabled = self.auth_enabled_checkbox.isChecked() bearer_token = self.bearer_token_input.text() bearer_prefix = self.bearer_prefix_input.text() basic_username = self.basic_username_input.text() basic_password = self.basic_password_input.text() # Получаем параметры из таблицы params = self.get_params_dict() params_json = json.dumps(params, ensure_ascii=False) # Обновляем данные запроса в дереве self.current_request_item.setData(0, Qt.ItemDataRole.UserRole, { "method": method, "url": url, "headers": headers if headers.strip() else "{}", "body": body, "params": params_json, "auth_type": auth_type, "auth_enabled": auth_enabled, "bearer_token": bearer_token, "bearer_prefix": bearer_prefix, "basic_username": basic_username, "basic_password": basic_password }) def save_database_as(self): """Сохраняет данные в файл базы данных с выбором нового имени""" # Сохраняем данные текущего выбранного запроса перед сохранением в БД if self.current_request_item and self.current_request_item.parent() is not None: self.save_current_request_data() # Получаем путь к корню проекта project_root = os.path.dirname(os.path.abspath(__file__)) # Определяем начальный путь для диалога if self.current_db_path: # Если есть открытый файл, используем его директорию и имя как основу initial_path = self.current_db_path else: # Иначе используем корень проекта с именем по умолчанию initial_path = os.path.join(project_root, "restman.db") # Всегда показываем диалог сохранения file_path, _ = QFileDialog.getSaveFileName( self, "Сохранить базу данных как...", initial_path, "SQLite Database (*.db *.sqlite *.sqlite3);;All Files (*)" ) if file_path: try: # Извлекаем данные из дерева groups_data = self.get_groups_data() # Сохраняем в БД db = Database(file_path) success = db.save_groups_and_requests(groups_data) db.close() if success: # Сохраняем путь к текущему файлу self.current_db_path = file_path # Сохраняем путь к последней открытой БД self.settings.setValue("last_database_path", file_path) # Обновляем заголовок окна self.setWindowTitle(f"RESTman - REST API Tester - {os.path.basename(file_path)}") QMessageBox.information( self, "Успех", f"Данные успешно сохранены в файл:\n{file_path}" ) else: QMessageBox.critical( self, "Ошибка", "Не удалось сохранить данные в базу данных" ) except Exception as e: QMessageBox.critical( self, "Ошибка", f"Не удалось сохранить базу данных:\n{str(e)}" ) def set_theme(self, theme): """Устанавливает тему приложения""" self.current_theme = theme # Получаем экземпляр приложения app = QApplication.instance() # Обновляем галочки в меню if theme == "light": self.light_theme_action.setChecked(True) self.dark_theme_action.setChecked(False) # Применяем светлую тему (по умолчанию) if app: app.setStyleSheet("") else: # dark self.light_theme_action.setChecked(False) self.dark_theme_action.setChecked(True) # Применяем темную тему dark_style = """ QMainWindow { background-color: #2b2b2b; color: #ffffff; } QWidget { background-color: #2b2b2b; color: #ffffff; } QGroupBox { border: 1px solid #555555; border-radius: 5px; margin-top: 10px; padding-top: 10px; font-weight: bold; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; } QTreeWidget { background-color: #3c3c3c; color: #ffffff; border: 1px solid #555555; selection-background-color: #0078d4; } QLineEdit, QTextEdit { background-color: #3c3c3c; color: #ffffff; border: 1px solid #555555; padding: 5px; } QComboBox { background-color: #3c3c3c; color: #ffffff; border: 1px solid #555555; padding: 5px; } QComboBox::drop-down { border: none; } QComboBox::down-arrow { image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 6px solid #ffffff; margin-right: 5px; } QComboBox QAbstractItemView { background-color: #3c3c3c; color: #ffffff; selection-background-color: #0078d4; border: 1px solid #555555; } QPushButton { background-color: #0078d4; color: #ffffff; border: none; padding: 8px 16px; border-radius: 4px; } QPushButton:hover { background-color: #106ebe; } QPushButton:pressed { background-color: #005a9e; } QPushButton#send_button { background-color: #4CAF50; color: white; font-weight: bold; padding: 10px; border-radius: 5px; } QPushButton#send_button:hover { background-color: #45a049; } QPushButton#send_button:pressed { background-color: #3d8b40; } QTabWidget::pane { border: 1px solid #555555; background-color: #2b2b2b; } QTabBar::tab { background-color: #3c3c3c; color: #ffffff; border: 1px solid #555555; padding: 8px 16px; margin-right: 2px; } QTabBar::tab:selected { background-color: #0078d4; } QTabBar::tab:hover { background-color: #106ebe; } QTableWidget { background-color: #3c3c3c; color: #ffffff; border: 1px solid #555555; gridline-color: #555555; } QTableWidget::item { background-color: #3c3c3c; color: #ffffff; } QTableWidget::item:selected { background-color: #0078d4; } QHeaderView::section { background-color: #2b2b2b; color: #ffffff; padding: 5px; border: 1px solid #555555; } QCheckBox { color: #ffffff; } QCheckBox::indicator { width: 18px; height: 18px; border: 1px solid #555555; background-color: #3c3c3c; border-radius: 3px; } QCheckBox::indicator:checked { background-color: #0078d4; border-color: #0078d4; } QMenuBar { background-color: #2b2b2b; color: #ffffff; } QMenuBar::item { background-color: transparent; padding: 4px 8px; } QMenuBar::item:selected { background-color: #3c3c3c; } QMenu { background-color: #3c3c3c; color: #ffffff; border: 1px solid #555555; } QMenu::item { padding: 5px 20px 5px 30px; } QMenu::item:selected { background-color: #0078d4; } QMenu::item:checked { background-color: #0078d4; } QLabel { color: #ffffff; } """ if app: app.setStyleSheet(dark_style) # Сохраняем выбранную тему self.settings.setValue("theme", theme) def load_theme(self): """Загружает сохраненную тему из настроек""" saved_theme = self.settings.value("theme", "light") self.set_theme(saved_theme) def main(): app = QApplication(sys.argv) # Устанавливаем стиль приложения app.setStyle("Fusion") icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "icon.png") if os.path.exists(icon_path): app.setWindowIcon(QIcon(icon_path)) window = MainWindow() if os.path.exists(icon_path): window.setWindowIcon(QIcon(icon_path)) window.show() sys.exit(app.exec()) if __name__ == "__main__": main()