统一应用顶部栏与设置入口

This commit is contained in:
张家训
2026-08-01 14:59:08 +08:00
parent 0a24a9b52b
commit 145b6559b8
5 changed files with 148 additions and 24 deletions
@@ -111,9 +111,9 @@ public class WorkUtilsApplication implements CommandLineRunner {
Platform.runLater(Theme::initTheme);
// 设置导航栏刷新回调(插件管理中启用/禁用后刷新侧边栏)
settingsService.setNavItemsRefresher(items -> Platform.runLater(() -> {
settingsService.setNavItemsRefresher(ignoredItems -> Platform.runLater(() -> {
if (mainWindow != null) {
mainWindow.refreshNavItems(items);
mainWindow.refreshNavItems(buildNavItems());
// 重新绑定插件面板
for (Plugin plugin : pluginManager.getEnabledPlugins()) {
PluginDescriptor desc = plugin.getDescriptor();
@@ -131,7 +131,7 @@ public class WorkUtilsApplication implements CommandLineRunner {
CountDownLatch windowClosed = new CountDownLatch(1);
Platform.runLater(() -> {
try {
mainWindow = new MainWindow(navItems, properties);
mainWindow = new MainWindow(navItems, properties, this::openSettingsEmbedded);
mainWindow.showAndWait();
} catch (Exception e) {
log.error("主窗口创建失败", e);
@@ -164,14 +164,6 @@ public class WorkUtilsApplication implements CommandLineRunner {
));
}
// 系统设置 — 内嵌为 Tab
items.add(new MainWindow.NavItem(
"⚙️", "系统设置",
"配置系统路径、数据库连接、项目路径、多数据源、字体大小等",
this::openSettingsEmbedded,
"__settings__"
));
return items;
}
@@ -8,7 +8,10 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
@@ -25,6 +28,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Main application window and shell-level plugin content host. */
public class MainWindow {
@@ -43,11 +47,17 @@ public class MainWindow {
private FontIcon toolbarIcon;
private Label toolbarTitle;
public MainWindow(List<NavItem> navItems, WorkUtilsProperties properties) {
public MainWindow(List<NavItem> navItems, WorkUtilsProperties properties, Runnable openSettings) {
this.navItems = new ArrayList<>(navItems);
this.properties = properties;
this.stage = new Stage();
buildUI();
buildUI(Objects.requireNonNull(openSettings, "openSettings must not be null"));
}
/** Compatibility constructor for callers that do not expose settings. */
public MainWindow(List<NavItem> navItems, WorkUtilsProperties properties) {
this(navItems, properties, () -> {
});
}
public void refreshNavItems(List<NavItem> newItems) {
@@ -74,7 +84,7 @@ public class MainWindow {
}
}
private void buildUI() {
private void buildUI(Runnable openSettings) {
setAppIcon();
stage.setTitle("WorkUtils — 开发工作集");
stage.setWidth(1280);
@@ -84,7 +94,7 @@ public class MainWindow {
stage.setOnCloseRequest(event -> embeddedCache.clear());
BorderPane root = new BorderPane();
root.setTop(buildUnifiedHeader());
root.setTop(buildUnifiedHeader(openSettings));
navigationRail = new NavigationRail(navItems, this::navigateTo);
contentArea = new StackPane();
@@ -100,8 +110,9 @@ public class MainWindow {
showWelcomePage();
}
private HBox buildUnifiedHeader() {
private HBox buildUnifiedHeader(Runnable openSettings) {
HBox bar = new HBox(Theme.S1);
bar.setId("app-header");
bar.setAlignment(Pos.CENTER_LEFT);
bar.setPadding(new Insets(0, Theme.S5, 0, Theme.S4));
bar.setMinHeight(DesignTokens.HEADER_HEIGHT);
@@ -109,26 +120,38 @@ public class MainWindow {
bar.setMaxHeight(DesignTokens.HEADER_HEIGHT);
bar.getStyleClass().add(Theme.CSS_HEADER_BAR);
FontIcon brand = IconFactory.create(AppIcon.APP, 20);
bar.getChildren().add(brand);
Button menuButton = new Button(null, IconFactory.createButtonIcon(AppIcon.MENU, "切换导航栏"));
menuButton.setId("menu-button");
menuButton.setTooltip(new Tooltip("切换导航栏"));
menuButton.setOnAction(event -> navigationRail.toggle());
Region spacerLeft = new Region();
bar.getChildren().add(spacerLeft);
Label brand = new Label("WorkUtils", IconFactory.create(AppIcon.APP, 20));
brand.setContentDisplay(ContentDisplay.LEFT);
brand.setFont(Theme.sectionTitle());
pluginToolbar = new HBox(Theme.S2);
pluginToolbar.setAlignment(Pos.CENTER_LEFT);
pluginToolbar.setVisible(false);
pluginToolbar.setManaged(false);
FontIcon chevron = IconFactory.create(AppIcon.CHEVRON_RIGHT, 14);
toolbarIcon = IconFactory.create(AppIcon.APP, 18);
toolbarTitle = new Label();
toolbarTitle.setFont(Theme.body());
pluginToolbar.getChildren().addAll(toolbarIcon, toolbarTitle);
bar.getChildren().add(pluginToolbar);
toolbarTitle.setFont(Theme.pageTitle());
toolbarTitle.getStyleClass().add(Theme.CSS_PAGE_TITLE);
pluginToolbar.getChildren().addAll(chevron, toolbarIcon, toolbarTitle);
Region spacerRight = new Region();
HBox.setHgrow(spacerRight, Priority.ALWAYS);
bar.getChildren().add(spacerRight);
Button settingsButton = new Button(
null, IconFactory.createButtonIcon(AppIcon.SETTINGS, "系统设置"));
settingsButton.setId("settings-button");
settingsButton.setTooltip(new Tooltip("系统设置"));
settingsButton.setOnAction(event -> openSettings.run());
bar.getChildren().addAll(
menuButton, brand, pluginToolbar, spacerRight, settingsButton);
return bar;
}
@@ -0,0 +1,31 @@
package com.example.workutils.system.ui.shell;
import com.example.workutils.system.ui.Theme;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import java.util.Objects;
/** Semantic title block for plugin content pages. */
public final class PageHeader extends VBox {
public PageHeader(String title) {
this(title, null);
}
public PageHeader(String title, String description) {
super(Theme.S1);
Label titleLabel = new Label(Objects.requireNonNull(title, "title must not be null"));
titleLabel.setFont(Theme.pageTitle());
titleLabel.getStyleClass().add(Theme.CSS_PAGE_TITLE);
getChildren().add(titleLabel);
if (description != null && !description.isBlank()) {
Label descriptionLabel = new Label(description);
descriptionLabel.setFont(Theme.secondary());
descriptionLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT);
getChildren().add(descriptionLabel);
}
}
}
@@ -39,6 +39,7 @@ class DefaultPluginConfigurationTest {
PluginManager manager = new PluginManager(plugins, properties);
manager.init();
assertEquals(7, manager.getEnabledPlugins().size());
assertEquals(
List.of("scan", "diff", "api-doc", "git-sync", "git-review",
"mybatisexport", "mybatisgenerator"),
@@ -0,0 +1,77 @@
package com.example.workutils.system.ui;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MainWindowArchitectureTest {
@Test
void settingsIsAUniqueHeaderActionInsteadOfANavigationItem() throws Exception {
String application = source("src/main/java/com/example/workutils/WorkUtilsApplication.java");
String buildNavItems = between(application,
"private List<MainWindow.NavItem> buildNavItems()",
"// ==================== 内嵌优先打开");
String mainWindow = source("src/main/java/com/example/workutils/system/ui/MainWindow.java");
assertFalse(buildNavItems.contains("__settings__"));
assertFalse(buildNavItems.contains("系统设置"));
assertTrue(application.contains("mainWindow.refreshNavItems(buildNavItems())"));
assertTrue(application.contains(
"new MainWindow(navItems, properties, this::openSettingsEmbedded)"));
assertTrue(mainWindow.contains(
"MainWindow(List<NavItem> navItems, WorkUtilsProperties properties, Runnable openSettings)"));
assertTrue(mainWindow.contains("IconFactory.createButtonIcon(AppIcon.SETTINGS"));
assertTrue(mainWindow.contains("openSettings.run()"));
}
@Test
void headerUsesDesignTokensAndFeatherSemanticsWithoutCustomWindowChrome() throws Exception {
String mainWindow = source("src/main/java/com/example/workutils/system/ui/MainWindow.java");
String header = between(mainWindow, "private HBox buildUnifiedHeader", "private void navigateTo");
assertTrue(header.contains("DesignTokens.HEADER_HEIGHT"));
assertTrue(header.contains("IconFactory.createButtonIcon(AppIcon.MENU"));
assertTrue(header.contains("navigationRail.toggle()"));
assertTrue(header.contains("IconFactory.create(AppIcon.APP"));
assertTrue(header.contains("IconFactory.create(AppIcon.CHEVRON_RIGHT"));
assertTrue(header.contains("IconFactory.createButtonIcon(AppIcon.SETTINGS"));
assertEquals(1, occurrences(header, "AppIcon.SETTINGS"));
assertEquals(1, occurrences(header, "openSettings.run()"));
assertFalse(header.contains("SVGPath"));
assertFalse(header.contains("<svg"));
assertFalse(mainWindow.contains("StageStyle.UNDECORATED"));
assertFalse(mainWindow.contains("initStyle("));
}
@Test
void pageHeaderExposesSemanticTitleAndOptionalDescription() throws Exception {
String pageHeader = source(
"src/main/java/com/example/workutils/system/ui/shell/PageHeader.java");
assertTrue(pageHeader.contains("Theme.CSS_PAGE_TITLE"));
assertTrue(pageHeader.contains("Theme.CSS_SECONDARY_TEXT"));
assertTrue(pageHeader.contains("description.isBlank()"));
}
private static String source(String path) throws Exception {
return Files.readString(Path.of(path));
}
private static String between(String source, String start, String end) {
int startIndex = source.indexOf(start);
int endIndex = source.indexOf(end, startIndex);
assertTrue(startIndex >= 0, () -> "Missing source marker: " + start);
assertTrue(endIndex > startIndex, () -> "Missing source marker: " + end);
return source.substring(startIndex, endIndex);
}
private static int occurrences(String source, String needle) {
return source.split(java.util.regex.Pattern.quote(needle), -1).length - 1;
}
}