菜单重构完成

This commit is contained in:
张家训
2026-07-29 14:07:01 +08:00
parent 5f1bceb767
commit 6b855a42b8
5 changed files with 381 additions and 117 deletions
@@ -1,7 +1,11 @@
package com.example.workutils.system.ui;
import com.example.workutils.system.config.WorkUtilsProperties;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
@@ -10,11 +14,15 @@ import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import javafx.stage.Stage;
import javafx.util.Duration;
import java.io.InputStream;
import java.util.*;
@@ -22,7 +30,7 @@ import java.util.*;
/**
* 工作工具集主窗口 —— AtlantaFX 重构版
* <p>
* 左侧导航栏 + 右侧内容区,使用 CSS 类控制样式。
* 左侧导航栏(ListView)+ 右侧内容区,使用 CSS 类控制样式。
*/
public class MainWindow {
@@ -31,6 +39,7 @@ public class MainWindow {
private final WorkUtilsProperties properties;
private VBox navBox;
private ListView<NavItem> navList;
private StackPane contentArea;
private NavItem activeNavItem;
private String activePluginId;
@@ -44,7 +53,6 @@ public class MainWindow {
private Label appLabel;
private Label welcomeIcon, welcomeHeading, welcomeDesc;
private final List<Button> navButtons = new ArrayList<>();
private final Map<String, EmbeddedEntry> embeddedCache = new LinkedHashMap<>();
/** 响应式缩放因子 */
@@ -71,15 +79,23 @@ public class MainWindow {
private void updateScale(double width) {
if (Double.isNaN(width) || width <= 0) return;
double newScale = Math.max(0.85, Math.min(1.4, width / 1280.0));
if (Math.abs(newScale - responsiveScale) > 0.025) {
responsiveScale = newScale;
refreshResponsive();
}
// debounce: resize 时频繁触发,延迟 150ms 才真正刷新,避免 UI 卡顿
if (scaleDebounce != null) scaleDebounce.stop();
scaleDebounce = new javafx.animation.Timeline(
new javafx.animation.KeyFrame(javafx.util.Duration.millis(150),
e -> {
if (Math.abs(newScale - responsiveScale) > 0.025) {
responsiveScale = newScale;
refreshResponsive();
}
}));
scaleDebounce.play();
}
private javafx.animation.Timeline scaleDebounce;
private void refreshResponsive() {
appLabel.setFont(rbold(14));
for (Button btn : navButtons) btn.setFont(rfont(13));
double navMin = computeNavMinBaseWidth() * responsiveScale;
navBox.setMinWidth(navMin);
@@ -97,6 +113,9 @@ public class MainWindow {
toolbarIcon.setFont(rfont(18));
toolbarTitle.setFont(rbold(14));
// ListView 自动处理 cell 渲染;cell 内部用 rfont 在 updateItem 中读取最新 scale
if (navList != null) navList.refresh();
}
private double computeNavMinBaseWidth() {
@@ -108,34 +127,12 @@ public class MainWindow {
* 刷新导航栏(插件启用/禁用后调用)
*/
public void refreshNavItems(List<NavItem> newItems) {
navButtons.clear();
navBox.getChildren().clear();
// 品牌区
navBox.getChildren().add(appLabel);
// 分割线
Separator sep = new Separator();
sep.getStyleClass().add(Theme.CSS_NAV_SEP);
sep.setPadding(new Insets(0));
navBox.getChildren().add(sep);
this.navItems.clear();
this.navItems.addAll(newItems);
// 导航按钮
for (NavItem item : newItems) {
Button btn = createNavButton(item);
navButtons.add(btn);
navBox.getChildren().add(btn);
if (navList != null) {
navList.setItems(FXCollections.observableArrayList(navItems));
navList.getSelectionModel().clearSelection();
}
// 弹性空间
Region spacer = new Region();
VBox.setVgrow(spacer, Priority.ALWAYS);
navBox.getChildren().add(spacer);
// 回到欢迎页
showWelcomePage();
}
@@ -277,87 +274,144 @@ public class MainWindow {
sep.setPadding(new Insets(0));
navBox.getChildren().add(sep);
// 导航按钮
for (NavItem item : navItems) {
Button btn = createNavButton(item);
navButtons.add(btn);
navBox.getChildren().add(btn);
}
// ====== 标准化 ListView 导航 ======
navList = new ListView<>(FXCollections.observableArrayList(navItems));
navList.getStyleClass().add(Theme.CSS_NAV_LIST);
navList.setStyle("-fx-background-color: transparent;"); // inline 兜底
navList.setCellFactory(lv -> new NavListCell());
navList.getSelectionModel().selectedItemProperty().addListener(
(obs, old, newVal) -> {
if (newVal != null) {
this.activeNavItem = newVal;
if (newVal.action != null) newVal.action.run();
showEmbeddedFor(newVal);
// 阻止 ListView 默认把选中项滚到中部
// 延迟到当前事件循环结束后,让我们的滚动覆盖默认行为
Platform.runLater(() -> {
int idx = navList.getSelectionModel().getSelectedIndex();
if (idx > 0) navList.scrollTo(idx - 1);
});
}
});
// 弹性空间
Region spacer = new Region();
VBox.setVgrow(spacer, Priority.ALWAYS);
navBox.getChildren().add(spacer);
// ListView 撑满 VBox 剩余空间,内容溢出时自动滚动
VBox.setVgrow(navList, Priority.ALWAYS);
navBox.getChildren().add(navList);
return navBox;
}
private Button createNavButton(NavItem item) {
Button btn = new Button();
btn.setMaxWidth(Double.MAX_VALUE);
btn.setAlignment(Pos.CENTER_LEFT);
btn.setPadding(new Insets(12, 16, 12, 16));
btn.setCursor(Cursor.HAND);
/**
* 自定义导航 Cell —— 包含图标、文本、左侧指示条、点击涟漪。
* 配合 ListView 自带的选择/键盘/滚动行为。
*/
private class NavListCell extends ListCell<NavItem> {
// 使用 HBox 分离图标和文字,图标区域固定宽度确保对齐
HBox content = new HBox(8);
content.setAlignment(Pos.CENTER_LEFT);
Label iconLabel = new Label(item.icon);
iconLabel.setPrefWidth(24); // 固定图标区域宽度
iconLabel.setAlignment(Pos.CENTER_LEFT);
iconLabel.setFont(rfont(13));
iconLabel.setTextFill(Color.WHITE);
Label nameLabel = new Label(item.name);
nameLabel.setFont(rfont(13));
nameLabel.setTextFill(Color.WHITE);
content.getChildren().addAll(iconLabel, nameLabel);
btn.setGraphic(content);
btn.setText("");
private final Rectangle indicator;
private final Label iconLabel;
private final Label nameLabel;
private final Circle ripple;
private final StackPane cellRoot;
btn.getStyleClass().add(Theme.CSS_NAV_BTN);
NavListCell() {
super();
btn.setOnMouseEntered(e -> {
// AtlantaFX 默认 hover 效果 + 我们增强
});
btn.setOnMouseExited(e -> {
// AtlantaFX 恢复
});
btn.setOnAction(e -> { selectNavItem(item); if (item.action != null) item.action.run(); });
indicator = new Rectangle(4, 28);
indicator.getStyleClass().add(Theme.CSS_NAV_INDICATOR);
indicator.setMouseTransparent(true);
return btn;
}
iconLabel = new Label();
iconLabel.setPrefWidth(24);
iconLabel.setAlignment(Pos.CENTER_LEFT);
iconLabel.setMouseTransparent(true);
private void selectNavItem(NavItem item) {
this.activeNavItem = item;
Color white = Color.WHITE;
Color activeColor = Color.web("#60a5fa");
for (int i = 0; i < navItems.size(); i++) {
Button btn = navButtons.get(i);
if (navItems.get(i) == item) {
btn.getStyleClass().remove(Theme.CSS_NAV_BTN);
btn.getStyleClass().add(Theme.CSS_NAV_BTN_ACTIVE);
btn.setStyle("");
setNavButtonTextColor(btn, activeColor);
} else {
btn.getStyleClass().remove(Theme.CSS_NAV_BTN_ACTIVE);
if (!btn.getStyleClass().contains(Theme.CSS_NAV_BTN)) {
btn.getStyleClass().add(Theme.CSS_NAV_BTN);
nameLabel = new Label();
nameLabel.setMouseTransparent(true);
// 涟漪层(点击时扩散)
ripple = new Circle(0);
ripple.getStyleClass().add(Theme.CSS_NAV_RIPPLE);
ripple.setMouseTransparent(true);
// 关键:不参与 layout,防止动画过程撑大 StackPane 高度
ripple.setManaged(false);
HBox content = new HBox(10, indicator, iconLabel, nameLabel);
content.setAlignment(Pos.CENTER_LEFT);
content.setMouseTransparent(true);
cellRoot = new StackPane(ripple, content);
cellRoot.setAlignment(Pos.CENTER_LEFT);
setGraphic(cellRoot);
setText(null);
getStyleClass().add(Theme.CSS_NAV_CELL);
// 监听 selected 变化以更新 CSS class(必须在 ripple 初始化之后)
// 直接设置 textFill 绕过 AtlantaFX CSS 优先级问题
selectedProperty().addListener((obs, old, isSelected) -> {
updateStyleClass();
if (isSelected) {
ripple.setOpacity(0);
}
btn.setStyle("");
setNavButtonTextColor(btn, white);
}
// 强制 text-fill:深色背景统一用白色
nameLabel.setTextFill(Color.WHITE);
iconLabel.setTextFill(Color.WHITE);
});
// 点击涟漪动画
addEventHandler(MouseEvent.MOUSE_PRESSED, this::onCellPressed);
}
showEmbeddedFor(item);
}
/** 递归设置导航按钮内所有 Label 的文字颜色 */
private void setNavButtonTextColor(Button btn, Color color) {
if (btn.getGraphic() instanceof HBox) {
for (javafx.scene.Node child : ((HBox) btn.getGraphic()).getChildren()) {
if (child instanceof Label) {
((Label) child).setTextFill(color);
}
@Override
protected void updateItem(NavItem item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setGraphic(null);
updateStyleClass();
return;
}
iconLabel.setText(item.icon);
iconLabel.setFont(rfont(13));
nameLabel.setText(item.name);
nameLabel.setFont(rfont(13));
// 直接在代码中强制 text-fill,深色导航栏统一用白色
nameLabel.setTextFill(Color.WHITE);
iconLabel.setTextFill(Color.WHITE);
setGraphic(cellRoot);
updateStyleClass();
}
private void updateStyleClass() {
getStyleClass().removeAll(Theme.CSS_NAV_CELL, Theme.CSS_NAV_CELL_ACTIVE);
if (isEmpty()) return;
boolean active = isSelected();
getStyleClass().add(active ? Theme.CSS_NAV_CELL_ACTIVE : Theme.CSS_NAV_CELL);
}
/** 点击涟漪扩散动画 */
private void onCellPressed(MouseEvent e) {
if (isEmpty()) return;
double x = e.getX();
double y = e.getY();
ripple.setCenterX(x);
ripple.setCenterY(y);
ripple.setRadius(0);
ripple.setOpacity(0.6);
double maxRadius = Math.max(getWidth(), getHeight());
Timeline anim = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(ripple.radiusProperty(), 0)),
new KeyFrame(Duration.millis(500),
new KeyValue(ripple.radiusProperty(), maxRadius),
new KeyValue(ripple.opacityProperty(), 0))
);
// 关键:动画结束必须重置 radius 为 0,否则 Circle 会撑大 StackPane 高度
anim.setOnFinished(ev -> {
ripple.setRadius(0);
ripple.setOpacity(0);
});
anim.play();
}
}
@@ -97,8 +97,11 @@ public final class Theme {
public static final String CSS_NAV_BAR = "nav-bar";
public static final String CSS_NAV_BRAND = "nav-brand";
public static final String CSS_NAV_SEP = "nav-separator";
public static final String CSS_NAV_BTN = "nav-btn";
public static final String CSS_NAV_BTN_ACTIVE = "nav-btn-active";
public static final String CSS_NAV_LIST = "nav-list";
public static final String CSS_NAV_CELL = "nav-cell";
public static final String CSS_NAV_CELL_ACTIVE = "nav-cell-active";
public static final String CSS_NAV_INDICATOR = "nav-indicator";
public static final String CSS_NAV_RIPPLE = "nav-ripple";
public static final String CSS_NAV_VERSION = "nav-version";
public static final String CSS_HEADER_BAR = "header-bar";