Add source
This commit is contained in:
13
src/main/java/com/dsol/pki_management/app/Launcher.java
Normal file
13
src/main/java/com/dsol/pki_management/app/Launcher.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.dsol.pki_management.app;
|
||||
|
||||
import javafx.application.Application;
|
||||
|
||||
/**
|
||||
* Точка входа в приложение
|
||||
*/
|
||||
public class Launcher {
|
||||
public static void main(String[] args) {
|
||||
Application.launch(PKIApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.dsol.pki_management.app;
|
||||
|
||||
import com.dsol.pki_management.controllers.MainController;
|
||||
import javafx.application.Application;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Главный класс приложения PKI Management
|
||||
*/
|
||||
public class PKIApplication extends Application {
|
||||
@Override
|
||||
public void start(Stage stage) throws IOException {
|
||||
FXMLLoader fxmlLoader = new FXMLLoader(PKIApplication.class.getResource("/com/dsol/pki_management/views/main.fxml"));
|
||||
fxmlLoader.setController(new MainController());
|
||||
Scene scene = new Scene(fxmlLoader.load());
|
||||
stage.setTitle("PKI Management");
|
||||
stage.setScene(scene);
|
||||
stage.show();
|
||||
}
|
||||
}
|
||||
|
||||
122
src/main/java/com/dsol/pki_management/components/MenuItem.java
Normal file
122
src/main/java/com/dsol/pki_management/components/MenuItem.java
Normal file
@@ -0,0 +1,122 @@
|
||||
package com.dsol.pki_management.components;
|
||||
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Separator;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MenuItem - визуальный блок в меню.
|
||||
* Не кликабельный, используется для группировки и визуального оформления подменю.
|
||||
*/
|
||||
public class MenuItem extends VBox {
|
||||
@FXML
|
||||
private Label titleLabel;
|
||||
@FXML
|
||||
private Separator separator;
|
||||
|
||||
private VBox subMenuContainer;
|
||||
|
||||
public MenuItem() {
|
||||
loadFXML();
|
||||
initializeSubMenuContainer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает FXML и применяет стили
|
||||
*/
|
||||
private void loadFXML() {
|
||||
FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/com/dsol/pki_management/components/menu-item.fxml"));
|
||||
fxmlLoader.setController(this);
|
||||
|
||||
try {
|
||||
VBox root = fxmlLoader.load();
|
||||
this.setAlignment(root.getAlignment());
|
||||
this.setSpacing(root.getSpacing());
|
||||
this.setStyle(root.getStyle());
|
||||
this.setPadding(root.getPadding());
|
||||
this.getChildren().addAll(root.getChildren());
|
||||
} catch (IOException exception) {
|
||||
throw new RuntimeException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует контейнер для SubMenuItem
|
||||
*/
|
||||
private void initializeSubMenuContainer() {
|
||||
subMenuContainer = new VBox();
|
||||
subMenuContainer.setSpacing(8.0);
|
||||
subMenuContainer.setPadding(new Insets(8, 0, 0, 0));
|
||||
getChildren().add(subMenuContainer);
|
||||
}
|
||||
|
||||
public MenuItem(String title) {
|
||||
this();
|
||||
setTitle(title);
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
if (titleLabel != null) {
|
||||
titleLabel.setText(title);
|
||||
}
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return titleLabel != null ? titleLabel.getText() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет SubMenuItem в этот блок меню
|
||||
* Может принимать как один, так и несколько элементов
|
||||
*/
|
||||
public void addSubMenuItems(SubMenuItem... subMenuItems) {
|
||||
if (subMenuItems.length > 0) {
|
||||
subMenuContainer.getChildren().addAll(subMenuItems);
|
||||
updateVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет SubMenuItem из этого блока меню
|
||||
*/
|
||||
public void removeSubMenuItem(SubMenuItem subMenuItem) {
|
||||
subMenuContainer.getChildren().remove(subMenuItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает все SubMenuItem из этого блока меню
|
||||
*/
|
||||
public void clearSubMenuItems() {
|
||||
subMenuContainer.getChildren().clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список всех SubMenuItem в этом блоке
|
||||
*/
|
||||
public List<SubMenuItem> getSubMenuItems() {
|
||||
return subMenuContainer.getChildren().stream()
|
||||
.filter(node -> node instanceof SubMenuItem)
|
||||
.map(node -> (SubMenuItem) node)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет видимость блока меню на основе видимости SubMenuItem
|
||||
* Если все SubMenuItem скрыты, блок меню также скрывается
|
||||
*/
|
||||
public void updateVisibility() {
|
||||
List<SubMenuItem> subMenuItems = getSubMenuItems();
|
||||
boolean shouldBeVisible = subMenuItems.isEmpty() ||
|
||||
subMenuItems.stream().anyMatch(SubMenuItem::isItemVisible);
|
||||
|
||||
this.setVisible(shouldBeVisible);
|
||||
this.setManaged(shouldBeVisible);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.dsol.pki_management.components;
|
||||
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* SubMenuItem - кликабельная ссылка для открытия функционала.
|
||||
*/
|
||||
public class SubMenuItem extends Button {
|
||||
private Consumer<MouseEvent> onClickHandler;
|
||||
private boolean itemVisible = true; // По умолчанию видимый
|
||||
|
||||
public SubMenuItem() {
|
||||
loadFXML();
|
||||
setOnAction(e -> {
|
||||
if (onClickHandler != null) {
|
||||
onClickHandler.accept(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public SubMenuItem(String text) {
|
||||
this();
|
||||
setText(text);
|
||||
}
|
||||
|
||||
public SubMenuItem(String text, Consumer<MouseEvent> onClickHandler) {
|
||||
this();
|
||||
setText(text);
|
||||
this.onClickHandler = onClickHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает FXML и применяет стили
|
||||
*/
|
||||
private void loadFXML() {
|
||||
FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/com/dsol/pki_management/components/sub-menu-item.fxml"));
|
||||
fxmlLoader.setController(this);
|
||||
|
||||
try {
|
||||
Button root = fxmlLoader.load();
|
||||
this.setMnemonicParsing(root.isMnemonicParsing());
|
||||
this.setMaxWidth(root.getMaxWidth());
|
||||
this.setStyle(root.getStyle());
|
||||
this.setText(root.getText());
|
||||
} catch (IOException exception) {
|
||||
throw new RuntimeException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void setOnClickHandler(Consumer<MouseEvent> handler) {
|
||||
this.onClickHandler = handler;
|
||||
}
|
||||
|
||||
public Consumer<MouseEvent> getOnClickHandler() {
|
||||
return onClickHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает видимость элемента меню
|
||||
* @param visible true - элемент видим, false - элемент скрыт
|
||||
*/
|
||||
public void setItemVisible(boolean visible) {
|
||||
this.itemVisible = visible;
|
||||
super.setVisible(visible);
|
||||
super.setManaged(visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, видим ли элемент меню
|
||||
* @return true если элемент видим, false если скрыт
|
||||
*/
|
||||
public boolean isItemVisible() {
|
||||
return itemVisible;
|
||||
}
|
||||
}
|
||||
|
||||
27
src/main/java/com/dsol/pki_management/config/AppConfig.java
Normal file
27
src/main/java/com/dsol/pki_management/config/AppConfig.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.dsol.pki_management.config;
|
||||
|
||||
/**
|
||||
* Константы и ключи для конфигурации приложения
|
||||
*/
|
||||
public class AppConfig {
|
||||
// Ключи конфигурации
|
||||
public static final String KEY_ADMIN_PASSWORD = "admin.password";
|
||||
public static final String KEY_LANGUAGE = "app.language";
|
||||
public static final String KEY_THEME = "app.theme";
|
||||
public static final String KEY_WINDOW_WIDTH = "window.width";
|
||||
public static final String KEY_WINDOW_HEIGHT = "window.height";
|
||||
public static final String KEY_WINDOW_X = "window.x";
|
||||
public static final String KEY_WINDOW_Y = "window.y";
|
||||
|
||||
// Значения по умолчанию
|
||||
public static final String DEFAULT_ADMIN_PASSWORD = "admin123";
|
||||
public static final String DEFAULT_LANGUAGE = "ru";
|
||||
public static final String DEFAULT_THEME = "light";
|
||||
public static final String DEFAULT_WINDOW_WIDTH = "960";
|
||||
public static final String DEFAULT_WINDOW_HEIGHT = "640";
|
||||
|
||||
private AppConfig() {
|
||||
// Утилитный класс, не должен быть инстанциирован
|
||||
}
|
||||
}
|
||||
|
||||
180
src/main/java/com/dsol/pki_management/config/ConfigManager.java
Normal file
180
src/main/java/com/dsol/pki_management/config/ConfigManager.java
Normal file
@@ -0,0 +1,180 @@
|
||||
package com.dsol.pki_management.config;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Properties;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Менеджер конфигурации приложения
|
||||
* Хранит конфигурацию в скрытой папке пользователя
|
||||
*/
|
||||
public class ConfigManager {
|
||||
private static final Logger logger = Logger.getLogger(ConfigManager.class.getName());
|
||||
private static final String CONFIG_DIR_NAME = "pki_management";
|
||||
private static final String CONFIG_FILE_NAME = "config.properties";
|
||||
|
||||
private static ConfigManager instance;
|
||||
private final Path configDir;
|
||||
private final Path configFile;
|
||||
private final Properties properties;
|
||||
|
||||
private ConfigManager() {
|
||||
String userHome = System.getProperty("user.home");
|
||||
this.configDir = Paths.get(userHome, CONFIG_DIR_NAME);
|
||||
this.configFile = configDir.resolve(CONFIG_FILE_NAME);
|
||||
this.properties = new Properties();
|
||||
|
||||
initializeConfigDirectory();
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает единственный экземпляр ConfigManager
|
||||
*/
|
||||
public static synchronized ConfigManager getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new ConfigManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует директорию конфигурации и делает её скрытой
|
||||
*/
|
||||
private void initializeConfigDirectory() {
|
||||
try {
|
||||
// Создаем директорию, если её нет
|
||||
if (!Files.exists(configDir)) {
|
||||
Files.createDirectories(configDir);
|
||||
logger.info("Создана директория конфигурации: " + configDir);
|
||||
}
|
||||
|
||||
// Делаем директорию скрытой
|
||||
setHiddenAttribute(configDir, true);
|
||||
|
||||
// Создаем файл конфигурации, если его нет
|
||||
if (!Files.exists(configFile)) {
|
||||
Files.createFile(configFile);
|
||||
logger.info("Создан файл конфигурации: " + configFile);
|
||||
}
|
||||
|
||||
// Делаем файл скрытым
|
||||
setHiddenAttribute(configFile, true);
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.severe("Ошибка при инициализации директории конфигурации: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает атрибут скрытого файла/папки
|
||||
* Для Windows использует DOS атрибут, для Unix-подобных систем - точку в начале имени
|
||||
*/
|
||||
private void setHiddenAttribute(Path path, boolean hidden) {
|
||||
try {
|
||||
if (isWindows()) {
|
||||
// Для Windows используем DOS атрибут
|
||||
Files.setAttribute(path, "dos:hidden", hidden);
|
||||
logger.info("Атрибут 'hidden' установлен для: " + path);
|
||||
} else {
|
||||
// Для Unix-подобных систем файлы с точкой в начале автоматически скрыты
|
||||
// Но так как пользователь указал конкретное имя, просто логируем
|
||||
logger.info("В Unix-подобных системах файл будет виден. Для скрытия используйте имя, начинающееся с точки.");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warning("Не удалось установить атрибут скрытого файла/папки для " + path + ": " + e.getMessage());
|
||||
} catch (UnsupportedOperationException e) {
|
||||
logger.warning("Операция установки атрибута не поддерживается для " + path + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает конфигурацию из файла
|
||||
*/
|
||||
public void loadConfig() {
|
||||
try (InputStream input = Files.newInputStream(configFile)) {
|
||||
properties.load(input);
|
||||
logger.info("Конфигурация загружена из файла");
|
||||
} catch (FileNotFoundException e) {
|
||||
logger.info("Файл конфигурации не найден, будет создан новый");
|
||||
saveConfig(); // Создаем файл с дефолтными значениями
|
||||
} catch (IOException e) {
|
||||
logger.severe("Ошибка при загрузке конфигурации: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет конфигурацию в файл
|
||||
*/
|
||||
public void saveConfig() {
|
||||
try (OutputStream output = Files.newOutputStream(configFile)) {
|
||||
properties.store(output, "PKI Management Configuration");
|
||||
logger.info("Конфигурация сохранена в файл");
|
||||
} catch (IOException e) {
|
||||
logger.severe("Ошибка при сохранении конфигурации: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает значение свойства
|
||||
*/
|
||||
public String getProperty(String key) {
|
||||
return properties.getProperty(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает значение свойства с дефолтным значением
|
||||
*/
|
||||
public String getProperty(String key, String defaultValue) {
|
||||
return properties.getProperty(key, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает значение свойства
|
||||
*/
|
||||
public void setProperty(String key, String value) {
|
||||
properties.setProperty(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет свойство
|
||||
*/
|
||||
public void removeProperty(String key) {
|
||||
properties.remove(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, существует ли свойство
|
||||
*/
|
||||
public boolean hasProperty(String key) {
|
||||
return properties.containsKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает путь к директории конфигурации
|
||||
*/
|
||||
public Path getConfigDir() {
|
||||
return configDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает путь к файлу конфигурации
|
||||
*/
|
||||
public Path getConfigFile() {
|
||||
return configFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли операционная система Windows
|
||||
*/
|
||||
private boolean isWindows() {
|
||||
return System.getProperty("os.name").toLowerCase().contains("windows");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.dsol.pki_management.controllers;
|
||||
|
||||
import com.dsol.pki_management.components.MenuItem;
|
||||
import com.dsol.pki_management.components.SubMenuItem;
|
||||
import com.dsol.pki_management.modules.test1.Test1Controller;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
|
||||
/**
|
||||
* Главный контроллер приложения
|
||||
*/
|
||||
public class MainController {
|
||||
@FXML
|
||||
private Button hamburgerButton;
|
||||
@FXML
|
||||
private VBox menuPane;
|
||||
@FXML
|
||||
private Pane menuOverlay;
|
||||
@FXML
|
||||
private VBox menuContent;
|
||||
@FXML
|
||||
private StackPane contentArea;
|
||||
|
||||
// Статический список всех SubMenuItem для доступа из других контроллеров
|
||||
private static final List<SubMenuItem> allSubMenuItems = new ArrayList<>();
|
||||
private static final List<MenuItem> allMenuItems = new ArrayList<>();
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
hamburgerButton.setOnAction(e -> openMenu());
|
||||
menuOverlay.setOnMouseClicked(e -> closeMenu());
|
||||
|
||||
setupMenuItems();
|
||||
}
|
||||
|
||||
private void setupMenuItems() {
|
||||
// Очищаем списки при повторной инициализации
|
||||
allSubMenuItems.clear();
|
||||
allMenuItems.clear();
|
||||
|
||||
// Определяем структуру меню
|
||||
MenuItem mainFunctions = createMenuItem("Основные функции",
|
||||
createSubMenuItem("Главная", e -> System.out.println("Открыта главная страница")),
|
||||
createSubMenuItem("Мониторинг", e -> System.out.println("Открыт мониторинг"))
|
||||
);
|
||||
|
||||
MenuItem management = createMenuItem("Управление",
|
||||
createSubMenuItem("Сертификаты", e -> System.out.println("Открыто управление сертификатами")),
|
||||
createSubMenuItem("Отчеты", e -> System.out.println("Открыты отчеты"))
|
||||
);
|
||||
|
||||
MenuItem testMenu = createMenuItem("Test",
|
||||
createSubMenuItem("Тест 1", e -> {
|
||||
loadTest1Module();
|
||||
closeMenu();
|
||||
})
|
||||
);
|
||||
|
||||
// Добавляем блоки меню в контент меню
|
||||
if (menuContent != null) {
|
||||
menuContent.getChildren().addAll(mainFunctions, management, testMenu);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает MenuItem с указанным названием и подменю
|
||||
*/
|
||||
private MenuItem createMenuItem(String title, SubMenuItem... subMenuItems) {
|
||||
MenuItem menuItem = new MenuItem(title);
|
||||
allMenuItems.add(menuItem);
|
||||
|
||||
if (subMenuItems.length > 0) {
|
||||
menuItem.addSubMenuItems(subMenuItems);
|
||||
for (SubMenuItem subMenuItem : subMenuItems) {
|
||||
allSubMenuItems.add(subMenuItem);
|
||||
}
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает SubMenuItem с указанным текстом и обработчиком
|
||||
*/
|
||||
private SubMenuItem createSubMenuItem(String text, Consumer<MouseEvent> onClickHandler) {
|
||||
return new SubMenuItem(text, onClickHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список всех SubMenuItem для доступа из других контроллеров
|
||||
*/
|
||||
public static List<SubMenuItem> getAllSubMenuItems() {
|
||||
return new ArrayList<>(allSubMenuItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список всех MenuItem для доступа из других контроллеров
|
||||
*/
|
||||
public static List<MenuItem> getAllMenuItems() {
|
||||
return new ArrayList<>(allMenuItems);
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void closeMenu() {
|
||||
menuPane.setVisible(false);
|
||||
menuPane.setManaged(false);
|
||||
menuOverlay.setVisible(false);
|
||||
menuOverlay.setManaged(false);
|
||||
}
|
||||
|
||||
private void openMenu() {
|
||||
menuPane.setVisible(true);
|
||||
menuPane.setManaged(true);
|
||||
menuOverlay.setVisible(true);
|
||||
menuOverlay.setManaged(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает модуль "Тест 1" в центральную область
|
||||
*/
|
||||
private void loadTest1Module() {
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/dsol/pki_management/modules/test1/test1-view.fxml"));
|
||||
VBox test1View = loader.load();
|
||||
|
||||
if (contentArea != null) {
|
||||
contentArea.getChildren().clear();
|
||||
contentArea.getChildren().add(test1View);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println("Ошибка загрузки модуля Тест 1: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает модуль настроек в центральную область
|
||||
*/
|
||||
@FXML
|
||||
private void loadSettingsModule() {
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/dsol/pki_management/modules/settings/settings-view.fxml"));
|
||||
VBox settingsView = loader.load();
|
||||
|
||||
if (contentArea != null) {
|
||||
contentArea.getChildren().clear();
|
||||
contentArea.getChildren().add(settingsView);
|
||||
}
|
||||
|
||||
closeMenu();
|
||||
} catch (IOException e) {
|
||||
System.err.println("Ошибка загрузки модуля настроек: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.dsol.pki_management.controllers;
|
||||
|
||||
import com.dsol.pki_management.components.MenuItem;
|
||||
import com.dsol.pki_management.components.SubMenuItem;
|
||||
import com.dsol.pki_management.config.AppConfig;
|
||||
import com.dsol.pki_management.config.ConfigManager;
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.stage.Modality;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Контроллер для модуля настроек
|
||||
*/
|
||||
public class SettingsController {
|
||||
@FXML
|
||||
private VBox userSettingsContainer;
|
||||
@FXML
|
||||
private VBox adminSettingsContainer;
|
||||
@FXML
|
||||
private Button adminAccessButton;
|
||||
@FXML
|
||||
private Button exitAdminButton;
|
||||
@FXML
|
||||
private Label statusLabel;
|
||||
@FXML
|
||||
private VBox subMenuItemsContainer;
|
||||
|
||||
private boolean isAdminMode = false;
|
||||
private final ConfigManager configManager = ConfigManager.getInstance();
|
||||
private Map<SubMenuItem, Text> subMenuItemTexts = new HashMap<>();
|
||||
|
||||
// Константы стилей
|
||||
private static final String STYLE_MENU_ITEM_CONTAINER = "-fx-background-color: #e9ecef; -fx-background-radius: 4;";
|
||||
private static final String STYLE_MENU_ITEM_LABEL = "-fx-font-size: 16px; -fx-font-weight: bold; -fx-text-fill: #212529;";
|
||||
private static final String STYLE_SUB_ITEM_VISIBLE = "-fx-font-size: 14px; -fx-fill: #495057; -fx-cursor: hand;";
|
||||
private static final String STYLE_SUB_ITEM_HIDDEN = "-fx-font-size: 14px; -fx-fill: #dc3545; -fx-cursor: hand;";
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
setContainerVisibility(userSettingsContainer, true);
|
||||
setContainerVisibility(adminSettingsContainer, false);
|
||||
|
||||
if (adminAccessButton != null) {
|
||||
adminAccessButton.setOnAction(e -> showAdminPasswordDialog());
|
||||
}
|
||||
if (exitAdminButton != null) {
|
||||
exitAdminButton.setOnAction(e -> disableAdminMode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает видимость контейнера
|
||||
*/
|
||||
private void setContainerVisibility(VBox container, boolean visible) {
|
||||
container.setVisible(visible);
|
||||
container.setManaged(visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает диалог для ввода пароля администратора
|
||||
*/
|
||||
private void showAdminPasswordDialog() {
|
||||
PasswordField passwordField = new PasswordField();
|
||||
passwordField.setPromptText("Пароль");
|
||||
|
||||
VBox content = new VBox(10);
|
||||
content.getChildren().addAll(new Label("Пароль:"), passwordField);
|
||||
content.setPadding(new Insets(20));
|
||||
|
||||
Dialog<String> dialog = new Dialog<>();
|
||||
dialog.setTitle("Админский доступ");
|
||||
dialog.setHeaderText("Введите пароль администратора");
|
||||
dialog.initModality(Modality.APPLICATION_MODAL);
|
||||
dialog.getDialogPane().setContent(content);
|
||||
|
||||
ButtonType loginButtonType = new ButtonType("Войти", ButtonBar.ButtonData.OK_DONE);
|
||||
dialog.getDialogPane().getButtonTypes().addAll(loginButtonType, ButtonType.CANCEL);
|
||||
((Button) dialog.getDialogPane().lookupButton(loginButtonType)).setDefaultButton(true);
|
||||
|
||||
dialog.setResultConverter(button -> button == loginButtonType ? passwordField.getText() : null);
|
||||
dialog.setOnShown(e -> Platform.runLater(passwordField::requestFocus));
|
||||
|
||||
dialog.showAndWait().ifPresent(password -> {
|
||||
String adminPassword = configManager.getProperty(AppConfig.KEY_ADMIN_PASSWORD, AppConfig.DEFAULT_ADMIN_PASSWORD);
|
||||
if (adminPassword.equals(password)) {
|
||||
enableAdminMode();
|
||||
} else {
|
||||
showErrorDialog("Неверный пароль", "Введен неверный пароль. Доступ запрещен.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Включает режим администратора
|
||||
*/
|
||||
private void enableAdminMode() {
|
||||
isAdminMode = true;
|
||||
setContainerVisibility(userSettingsContainer, false);
|
||||
setContainerVisibility(adminSettingsContainer, true);
|
||||
|
||||
if (statusLabel != null) {
|
||||
statusLabel.setText("Режим администратора активен");
|
||||
statusLabel.setStyle("-fx-text-fill: green;");
|
||||
}
|
||||
|
||||
loadSubMenuItemsControls();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает дерево для управления видимостью SubMenuItem
|
||||
*/
|
||||
private void loadSubMenuItemsControls() {
|
||||
if (subMenuItemsContainer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Очищаем контейнер
|
||||
subMenuItemsContainer.getChildren().clear();
|
||||
subMenuItemTexts.clear();
|
||||
|
||||
// Добавляем отступ для всего блока
|
||||
subMenuItemsContainer.setPadding(new Insets(15, 0, 15, 0));
|
||||
subMenuItemsContainer.setSpacing(8);
|
||||
|
||||
// Получаем все MenuItem и SubMenuItem из HelloController
|
||||
List<MenuItem> menuItems = MainController.getAllMenuItems();
|
||||
|
||||
if (menuItems.isEmpty()) {
|
||||
Label noItemsLabel = new Label("Нет элементов меню для управления");
|
||||
noItemsLabel.setStyle("-fx-text-fill: #6c757d;");
|
||||
subMenuItemsContainer.getChildren().add(noItemsLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем дерево: для каждого MenuItem показываем заголовок и его SubMenuItem
|
||||
for (MenuItem menuItem : menuItems) {
|
||||
subMenuItemsContainer.getChildren().add(createMenuItemHeader(menuItem.getTitle()));
|
||||
|
||||
for (SubMenuItem subMenuItem : menuItem.getSubMenuItems()) {
|
||||
Text subItemText = createSubMenuItemText(subMenuItem);
|
||||
subMenuItemTexts.put(subMenuItem, subItemText);
|
||||
subMenuItemsContainer.getChildren().add(createSubMenuItemContainer(subItemText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает заголовок MenuItem
|
||||
*/
|
||||
private HBox createMenuItemHeader(String title) {
|
||||
HBox container = new HBox();
|
||||
container.setPadding(new Insets(8, 12, 8, 12));
|
||||
container.setStyle(STYLE_MENU_ITEM_CONTAINER);
|
||||
|
||||
Label label = new Label("📁 " + title);
|
||||
label.setStyle(STYLE_MENU_ITEM_LABEL);
|
||||
container.getChildren().add(label);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает контейнер для SubMenuItem с отступом
|
||||
*/
|
||||
private HBox createSubMenuItemContainer(Text text) {
|
||||
HBox container = new HBox();
|
||||
container.setPadding(new Insets(4, 0, 4, 30));
|
||||
container.getChildren().add(text);
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает кликабельный Text для SubMenuItem
|
||||
*/
|
||||
private Text createSubMenuItemText(SubMenuItem subMenuItem) {
|
||||
Text text = new Text(" • " + subMenuItem.getText());
|
||||
|
||||
// Устанавливаем стиль в зависимости от видимости
|
||||
updateSubMenuItemTextStyle(text, subMenuItem.isItemVisible());
|
||||
|
||||
// Делаем Text кликабельным
|
||||
text.setStyle(text.getStyle() + " -fx-cursor: hand;");
|
||||
text.setOnMouseClicked(e -> toggleSubMenuItemVisibility(subMenuItem, text));
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает видимость SubMenuItem при клике
|
||||
*/
|
||||
private void toggleSubMenuItemVisibility(SubMenuItem subMenuItem, Text text) {
|
||||
boolean newVisibility = !subMenuItem.isItemVisible();
|
||||
subMenuItem.setItemVisible(newVisibility);
|
||||
updateSubMenuItemTextStyle(text, newVisibility);
|
||||
|
||||
// Обновляем видимость всех MenuItem
|
||||
MainController.getAllMenuItems().forEach(MenuItem::updateVisibility);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет стиль Text в зависимости от видимости
|
||||
*/
|
||||
private void updateSubMenuItemTextStyle(Text text, boolean isVisible) {
|
||||
text.setStyle(isVisible ? STYLE_SUB_ITEM_VISIBLE : STYLE_SUB_ITEM_HIDDEN);
|
||||
text.setStrikethrough(!isVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключает режим администратора
|
||||
*/
|
||||
@FXML
|
||||
private void disableAdminMode() {
|
||||
isAdminMode = false;
|
||||
setContainerVisibility(userSettingsContainer, true);
|
||||
setContainerVisibility(adminSettingsContainer, false);
|
||||
|
||||
if (statusLabel != null) {
|
||||
statusLabel.setText("");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает диалог с ошибкой
|
||||
*/
|
||||
private void showErrorDialog(String title, String message) {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setTitle(title);
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText(message);
|
||||
alert.showAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.dsol.pki_management.modules.test1;
|
||||
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Контроллер для модуля "Тест 1"
|
||||
*/
|
||||
public class Test1Controller {
|
||||
@FXML
|
||||
private VBox root;
|
||||
@FXML
|
||||
private Label titleLabel;
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
if (titleLabel != null) {
|
||||
titleLabel.setText("Модуль Тест 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.Separator?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx/21"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
alignment="TOP_LEFT"
|
||||
spacing="8.0"
|
||||
style="-fx-padding: 8 0 8 0;">
|
||||
<children>
|
||||
<Label fx:id="titleLabel"
|
||||
style="-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #495057;"
|
||||
text="Название блока"/>
|
||||
<Separator fx:id="separator"
|
||||
prefWidth="150.0"
|
||||
style="-fx-padding: 0 0 4 0;"/>
|
||||
</children>
|
||||
</VBox>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.Button?>
|
||||
|
||||
<Button xmlns="http://javafx.com/javafx/21"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
mnemonicParsing="false"
|
||||
maxWidth="Infinity"
|
||||
style="-fx-background-color: transparent; -fx-text-fill: #0d6efd; -fx-alignment: CENTER_LEFT; -fx-cursor: hand;"/>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.Separator?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
|
||||
<VBox fx:controller="com.dsol.pki_management.controllers.SettingsController"
|
||||
xmlns="http://javafx.com/javafx/21"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
alignment="TOP_LEFT"
|
||||
spacing="20.0"
|
||||
style="-fx-background-color: white; -fx-padding: 40;">
|
||||
<children>
|
||||
<Label style="-fx-font-size: 24px; -fx-font-weight: bold; -fx-text-fill: #0d6efd;"
|
||||
text="Настройки"/>
|
||||
<Separator/>
|
||||
|
||||
<!-- Пользовательские настройки -->
|
||||
<VBox fx:id="userSettingsContainer" spacing="15.0">
|
||||
<children>
|
||||
<Label style="-fx-font-size: 18px; -fx-font-weight: bold;"
|
||||
text="Пользовательские настройки"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
|
||||
text="• Язык интерфейса"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
|
||||
text="• Тема оформления"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
|
||||
text="• Уведомления"/>
|
||||
|
||||
<Separator/>
|
||||
|
||||
<Button fx:id="adminAccessButton"
|
||||
mnemonicParsing="false"
|
||||
style="-fx-background-color: #0d6efd; -fx-text-fill: white; -fx-padding: 10 20 10 20;"
|
||||
text="Админский доступ"/>
|
||||
</children>
|
||||
</VBox>
|
||||
|
||||
<!-- Админские настройки -->
|
||||
<VBox fx:id="adminSettingsContainer" spacing="15.0" visible="false" managed="false">
|
||||
<children>
|
||||
<Label style="-fx-font-size: 18px; -fx-font-weight: bold; -fx-text-fill: #dc3545;"
|
||||
text="Админские настройки"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
|
||||
text="• Управление пользователями"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
|
||||
text="• Системные параметры"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
|
||||
text="• Логи и аудит"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
|
||||
text="• Резервное копирование"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
|
||||
text="• Безопасность"/>
|
||||
|
||||
<Separator/>
|
||||
|
||||
<Label style="-fx-font-size: 16px; -fx-font-weight: bold; -fx-text-fill: #495057;"
|
||||
text="Управление видимостью элементов меню"/>
|
||||
<VBox fx:id="subMenuItemsContainer" spacing="8.0">
|
||||
<!-- Дерево MenuItem и SubMenuItem будет добавлено программно -->
|
||||
</VBox>
|
||||
|
||||
<Separator/>
|
||||
|
||||
<Button fx:id="exitAdminButton"
|
||||
mnemonicParsing="false"
|
||||
style="-fx-background-color: #dc3545; -fx-text-fill: white; -fx-padding: 10 20 10 20;"
|
||||
text="Выйти из админ режима"/>
|
||||
</children>
|
||||
</VBox>
|
||||
|
||||
<Label fx:id="statusLabel" style="-fx-font-size: 12px;"/>
|
||||
</children>
|
||||
</VBox>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
|
||||
<VBox fx:controller="com.dsol.pki_management.modules.test1.Test1Controller"
|
||||
xmlns="http://javafx.com/javafx/21"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
alignment="CENTER"
|
||||
spacing="20.0"
|
||||
style="-fx-background-color: white; -fx-padding: 40;">
|
||||
<children>
|
||||
<Label fx:id="titleLabel"
|
||||
style="-fx-font-size: 24px; -fx-font-weight: bold; -fx-text-fill: #0d6efd;"
|
||||
text="Модуль Тест 1"/>
|
||||
<Label style="-fx-font-size: 16px; -fx-text-fill: #495057;"
|
||||
text="Это функциональный модуль для тестирования"/>
|
||||
<Label style="-fx-font-size: 14px; -fx-text-fill: #6c757d;"
|
||||
text="Здесь можно добавить любой функционал"/>
|
||||
</children>
|
||||
</VBox>
|
||||
|
||||
80
src/main/resources/com/dsol/pki_management/views/main.fxml
Normal file
80
src/main/resources/com/dsol/pki_management/views/main.fxml
Normal file
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Hyperlink?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.Separator?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.layout.Pane?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<?import javafx.scene.layout.StackPane?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
|
||||
<StackPane prefWidth="960.0" prefHeight="640.0"
|
||||
xmlns="http://javafx.com/javafx/21"
|
||||
xmlns:fx="http://javafx.com/fxml/1">
|
||||
<children>
|
||||
<BorderPane prefWidth="960.0" prefHeight="640.0">
|
||||
<top>
|
||||
<VBox style="-fx-background-color: #f8f9fa;">
|
||||
<children>
|
||||
<HBox alignment="CENTER_LEFT" spacing="16.0" style="-fx-padding: 12 16 12 16;">
|
||||
<children>
|
||||
<Button fx:id="hamburgerButton"
|
||||
mnemonicParsing="false"
|
||||
style="-fx-background-color: transparent; -fx-border-color: #0d6efd; -fx-border-radius: 6; -fx-text-fill: #0d6efd;"
|
||||
text="Меню"/>
|
||||
<Label style="-fx-font-size: 20px; -fx-font-weight: bold;"
|
||||
text="PKI Management"/>
|
||||
</children>
|
||||
</HBox>
|
||||
</children>
|
||||
</VBox>
|
||||
</top>
|
||||
<center>
|
||||
<StackPane fx:id="contentArea" style="-fx-background-color: white;">
|
||||
<children>
|
||||
<Label style="-fx-font-size: 18px;" text="Основное содержимое приложения"/>
|
||||
</children>
|
||||
</StackPane>
|
||||
</center>
|
||||
</BorderPane>
|
||||
|
||||
<Pane fx:id="menuOverlay"
|
||||
visible="false" managed="false"
|
||||
style="-fx-background-color: rgba(0, 0, 0, 0.45);"/>
|
||||
|
||||
<VBox fx:id="menuPane"
|
||||
alignment="TOP_LEFT"
|
||||
prefWidth="200.0"
|
||||
maxWidth="200.0"
|
||||
minWidth="200.0"
|
||||
StackPane.alignment="CENTER_LEFT"
|
||||
style="-fx-background-color: #ffffff; -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.2), 15, 0, 2, 0);"
|
||||
visible="false" managed="false">
|
||||
<padding>
|
||||
<Insets top="24.0" right="24.0" bottom="24.0" left="24.0"/>
|
||||
</padding>
|
||||
<children>
|
||||
<Label style="-fx-font-size: 22px; -fx-font-weight: bold;" text="Навигация"/>
|
||||
<Separator prefWidth="150.0"/>
|
||||
<VBox fx:id="menuContent" spacing="12.0" VBox.vgrow="ALWAYS">
|
||||
<children>
|
||||
<!-- Компоненты MenuItem и SubMenuItem будут добавлены программно -->
|
||||
</children>
|
||||
</VBox>
|
||||
<Region VBox.vgrow="ALWAYS"/>
|
||||
<Separator prefWidth="150.0"/>
|
||||
<Hyperlink onAction="#closeMenu"
|
||||
style="-fx-text-fill: #6c757d; -fx-alignment: CENTER_LEFT;"
|
||||
text="Закрыть меню"/>
|
||||
<Hyperlink onAction="#loadSettingsModule"
|
||||
style="-fx-text-fill: #6c757d; -fx-alignment: CENTER_LEFT;"
|
||||
text="⚙️ Настройки"/>
|
||||
</children>
|
||||
</VBox>
|
||||
</children>
|
||||
</StackPane>
|
||||
|
||||
47
ui/main.html
Normal file
47
ui/main.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hamburger меню слева</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Кнопка гамбургера -->
|
||||
<nav class="navbar bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<button class="btn btn-outline-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasMenu" aria-controls="offcanvasMenu">
|
||||
<span class="navbar-toggler-icon"></span> Меню
|
||||
</button>
|
||||
<a class="navbar-brand ms-3" href="#">Мой сайт</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Offcanvas меню -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasMenu" aria-labelledby="offcanvasMenuLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="offcanvasMenuLabel">Навигация</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<!-- Тело меню с разделением на верхнюю и нижнюю часть -->
|
||||
<div class="offcanvas-body d-flex flex-column justify-content-between">
|
||||
<!-- Верхняя часть -->
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="#">Главная</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Нижняя часть -->
|
||||
<div class="border-top pt-3">
|
||||
<a class="nav-link text-secondary" href="#">
|
||||
⚙️ Настройки
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user