运行新增托盘图标,新增查看串口状态、关闭串口监听、推出的右键菜单
已添加2个文件
已修改2个文件
733 ■■■■■ 文件已修改
src/main/java/com/hwtd/mes/collect/DataAcquisitionApplication.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/hwtd/mes/collect/config/SystemTrayInitializer.java 676 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/hwtd/mes/collect/handler/SerialPortListener.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/tray.png 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/hwtd/mes/collect/DataAcquisitionApplication.java
@@ -1,13 +1,66 @@
package com.hwtd.mes.collect;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import javax.swing.JOptionPane;
import java.net.BindException;
@Slf4j
@SpringBootApplication
public class DataAcquisitionApplication {
    public static void main(String[] args) {
        SpringApplication.run(DataAcquisitionApplication.class, args);
        SpringApplication application = new SpringApplication(DataAcquisitionApplication.class);
        // ç³»ç»Ÿæ‰˜ç›˜å›¾æ ‡ä¾èµ– AWT:Spring Boot é»˜è®¤ java.awt.headless=true,这里必须关掉,否则托盘不会显示
        application.setHeadless(false);
        try {
            application.run(args);
        } catch (Throwable e) {
            // åŒå‡» jar å¯åŠ¨ç”¨çš„æ˜¯ javaw,没有控制台,启动失败时必须弹窗,否则用户什么都看不到
            showStartupError(e);
            System.exit(1);
        }
    }
    /**
     * å¯åŠ¨å¤±è´¥æç¤ºï¼šå†™æ—¥å¿— + å¼¹çª—
     */
    private static void showStartupError(Throwable error) {
        String message = buildStartupErrorMessage(error);
        try {
            log.error("数据采集器启动失败:{}", message, error);
        } catch (Throwable ignored) {
            // æ—¥å¿—系统还没初始化好时忽略
        }
        try {
            JOptionPane.showMessageDialog(null, message, "数据采集器启动失败", JOptionPane.ERROR_MESSAGE);
        } catch (Throwable t) {
            // æ²¡æœ‰æ¡Œé¢ä¼šè¯æ—¶å¼¹ä¸äº†çª—,只能靠日志
            System.err.println(message);
        }
    }
    /**
     * ç»„装启动失败提示:端口被占用时给出更明确的处理办法
     *
     * @param error å¯åЍ异叏
     * @return æç¤ºå†…容
     */
    public static String buildStartupErrorMessage(Throwable error) {
        Throwable root = error;
        while (root.getCause() != null && root.getCause() != root) {
            root = root.getCause();
        }
        String detail = root.getMessage() == null ? root.getClass().getName() : root.getMessage();
        if (root instanceof BindException
                || detail.contains("already in use")
                || detail.contains("Address already in use")) {
            return "服务端口已被占用,程序可能已经在运行。\n"
                    + "请先看任务栏右下角有没有采集器图标(右键 â†’ é€€å‡ºï¼‰ï¼Œ"
                    + "或在任务管理器里结束对应的 java è¿›ç¨‹åŽå†å¯åŠ¨ã€‚";
        }
        return "启动失败:" + detail + "\n详细错误请查看日志文件。";
    }
}
src/main/java/com/hwtd/mes/collect/config/SystemTrayInitializer.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,676 @@
package com.hwtd.mes.collect.config;
import com.hwtd.mes.collect.handler.SerialPortListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.annotation.PreDestroy;
import javax.imageio.ImageIO;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JSeparator;
import javax.swing.JWindow;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.Timer;
import javax.swing.WindowConstants;
import java.awt.AWTException;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Cursor;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.FlowLayout;
import java.awt.GridLayout;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.Image;
import java.awt.MouseInfo;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.InputStream;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/**
 * ç³»ç»Ÿæ‰˜ç›˜å›¾æ ‡ï¼šexe(或 jar)启动后在任务栏右下角托盘常驻一个小图标,右键菜单可以快速退出。
 * <p>
 * è¯´æ˜Žï¼š
 * <ul>
 *     <li>托盘依赖 AWT,而 Spring Boot é»˜è®¤ {@code java.awt.headless=true}(此时 {@link SystemTray#isSupported()} ä¸º false),
 *         æ‰€ä»¥å¯åŠ¨ç±»é‡Œå·²ç»ç”¨ {@code setHeadless(false)} å…³æŽ‰ï¼›</li>
 *     <li>右键菜单用 Swing è‡ªå·±ç”»ä¸€ä¸ªæ— è¾¹æ¡†çª—口({@link JWindow}),不用 AWT çš„ PopupMenu:
 *         AWT èœå•是 Windows åŽŸç”Ÿèœå•ï¼Œæ–‡å­—ç”¨ç³»ç»Ÿèœå•å­—ä½“ç»˜åˆ¶ï¼Œéƒ¨åˆ†æœºå™¨ä¸Šä¸å«ä¸­æ–‡å­—å½¢ä¼šæ˜¾ç¤ºæˆæ–¹å—ï¼Œè€Œä¸” {@code setFont} ä¸ç”Ÿæ•ˆï¼›</li>
 *     <li>菜单弹出位置和系统托盘菜单一致:显示在光标上方、右边缘与光标对齐,并保证不超出屏幕(多显示器时按光标所在屏幕计算);</li>
 *     <li>菜单收起方式:点击菜单外面(窗口失焦)、按 ESC、再右键一次、点了某一项之后,
 *         å¦å¤–还有一层兜底——鼠标离开菜单附近也会自动收起(避免个别环境窗口抢不到焦点导致菜单关不掉);</li>
 *     <li>菜单里的“查看串口状态”(或双击托盘图标)会弹出一个状态窗口:串口名称、监听状态、串口连接、缓存条数,每秒自动刷新;</li>
 *     <li>菜单里的“关闭串口”只关闭串口监听(不退出程序),方便现场把串口释放给别的程序用,结果用托盘气泡提示;</li>
 *     <li>图标优先读取 classpath ä¸‹çš„ {@code tray.png}(放一张 32x32 çš„ png å³å¯æ¢æˆè‡ªå·±çš„图标),没有就用代码画的默认图标;</li>
 *     <li>点“退出”会先关闭 Spring å®¹å™¨ï¼ˆè§¦å‘ {@code @PreDestroy},例如关闭串口监听、停止补帧线程),再结束进程;
 *         ç”¨ä»»åŠ¡ç®¡ç†å™¨ç»“æŸè¿›ç¨‹ã€æˆ–ç›´æŽ¥å…³æŽ‰ exe çš„æŽ§åˆ¶å°çª—口,则不会走这套清理流程;</li>
 *     <li>以 Windows æœåŠ¡ï¼ˆSession 0,无桌面)方式运行时托盘不会显示,代码会自动跳过,不影响接口使用。</li>
 * </ul>
 */
@Slf4j
@Component
public class SystemTrayInitializer implements ApplicationRunner {
    /** é»˜è®¤å›¾æ ‡å°ºå¯¸ */
    private static final int DEFAULT_ICON_SIZE = 32;
    /** èœå•背景色 */
    private static final Color MENU_BACKGROUND = Color.WHITE;
    /** èœå•项鼠标悬停背景色 */
    private static final Color MENU_HOVER_BACKGROUND = new Color(0xE8F0FE);
    /** èœå•边框颜色 */
    private static final Color MENU_BORDER = new Color(0xC8C8C8);
    @Value("${server.port:9527}")
    private int serverPort;
    @Value("${logging.file-location:logs}")
    private String logLocation;
    private final ApplicationContext applicationContext;
    /** æ‰˜ç›˜å›¾æ ‡ï¼Œé€€å‡ºæ—¶å…ˆç§»é™¤ */
    private TrayIcon trayIcon;
    /** å³é”®èœå•窗口,第一次右键时创建 */
    private JWindow popupWindow;
    /** çŠ¶æ€çª—å£ï¼Œç¬¬ä¸€æ¬¡ç‚¹â€œæŸ¥çœ‹é‡‡é›†çŠ¶æ€â€æ—¶åˆ›å»º */
    private JDialog statusDialog;
    /** çŠ¶æ€çª—å£çš„è‡ªåŠ¨åˆ·æ–°å®šæ—¶å™¨ï¼ˆæ¯ç§’åˆ·æ–°ä¸€æ¬¡ï¼‰ */
    private Timer statusRefreshTimer;
    /** çŠ¶æ€çª—å£é‡Œçš„å€¼ */
    private JLabel serialPortNameValue;
    private JLabel listeningValue;
    private JLabel portOpenValue;
    private JLabel dataSizeValue;
    /** å…œåº•收起检查:万一菜单窗口没抢到焦点,鼠标离开菜单后也能自动收起 */
    private Timer popupWatchdog;
    /** å¼¹å‡ºèœå•时的光标位置(托盘图标位置) */
    private Point popupAnchor;
    /** å…‰æ ‡è¿žç»­å‡ æ¬¡ä¸åœ¨èœå•附近 */
    private int outsideCount;
    public SystemTrayInitializer(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }
    @Override
    public void run(ApplicationArguments args) {
        if (!SystemTray.isSupported()) {
            log.warn("当前环境不支持系统托盘(headless æ¨¡å¼ã€æ— æ¡Œé¢ä¼šè¯æˆ–缺少 java.desktop æ¨¡å—),跳过托盘图标");
            return;
        }
        try {
            // AWT/Swing ç»„件统一在事件分发线程(EDT)里创建
            EventQueue.invokeAndWait(this::createTrayIcon);
        } catch (Exception e) {
            log.error("创建系统托盘图标失败:{}", e.getMessage(), e);
        }
    }
    /**
     * åˆ›å»ºæ‰˜ç›˜å›¾æ ‡ï¼Œå¹¶æ³¨å†Œé¼ æ ‡äº‹ä»¶ï¼šå³é”®å¼¹èœå•、左键双击打开采集状态
     */
    private void createTrayIcon() {
        trayIcon = new TrayIcon(loadTrayImage(), "亨旺特导MES数据采集器");
        trayIcon.setImageAutoSize(true);
        trayIcon.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseReleased(MouseEvent event) {
                if (event.isPopupTrigger() || event.getButton() == MouseEvent.BUTTON3) {
                    showPopupWindow();
                }
            }
            @Override
            public void mouseClicked(MouseEvent event) {
                if (event.getButton() == MouseEvent.BUTTON1 && event.getClickCount() >= 2) {
                    showStatusDialog();
                }
            }
        });
        try {
            SystemTray.getSystemTray().add(trayIcon);
            log.info("系统托盘图标已启动,右键托盘图标可以退出程序(端口 {})", serverPort);
        } catch (AWTException e) {
            log.error("添加系统托盘图标失败:{}", e.getMessage(), e);
        }
    }
    /**
     * åœ¨å…‰æ ‡ä½ç½®å¼¹å‡ºå³é”®èœå•;已经弹着就收起来
     */
    private void showPopupWindow() {
        Point mouse = MouseInfo.getPointerInfo() == null ? null : MouseInfo.getPointerInfo().getLocation();
        if (mouse == null) {
            return;
        }
        JWindow window = ensurePopupWindow(mouse);
        if (window.isVisible()) {
            hidePopupWindow();
            return;
        }
        Rectangle screen = screenConfigurationOf(mouse).getBounds();
        Dimension size = window.getSize();
        // å’Œç³»ç»Ÿæ‰˜ç›˜èœå•一样:显示在光标上方,右边缘与光标对齐
        int x = mouse.x - size.width;
        int y = mouse.y - size.height - 2;
        if (y < screen.y) {
            // ä¸Šæ–¹æ”¾ä¸ä¸‹ï¼ˆä¾‹å¦‚任务栏在顶部)就放到光标下方
            y = mouse.y + 12;
        }
        // ä¿è¯èœå•完整显示在屏幕内
        x = Math.max(screen.x, Math.min(x, screen.x + screen.width - size.width));
        y = Math.max(screen.y, Math.min(y, screen.y + screen.height - size.height));
        window.setLocation(x, y);
        window.setVisible(true);
        window.toFront();
        window.requestFocus();
        startPopupWatchdog(mouse);
    }
    /**
     * æ”¶èµ·å³é”®èœå•
     */
    private void hidePopupWindow() {
        stopPopupWatchdog();
        JWindow window = popupWindow;
        if (window != null && window.isVisible()) {
            window.setVisible(false);
        }
    }
    /**
     * å¯åŠ¨å…œåº•æ”¶èµ·æ£€æŸ¥ï¼šæ­£å¸¸æƒ…å†µä¸‹ç‚¹å‡»åˆ«å¤„çª—å£å¤±ç„¦å°±ä¼šæ”¶èµ·ï¼Œ
     * ä¸‡ä¸€çª—口没抢到焦点(部分环境会这样),靠鼠标离开菜单附近也能收起。
     */
    private void startPopupWatchdog(Point anchor) {
        stopPopupWatchdog();
        popupAnchor = anchor;
        outsideCount = 0;
        popupWatchdog = new Timer(250, event -> checkPopupDismiss());
        // ç•™ä¸€ç‚¹æ—¶é—´ç»™ç”¨æˆ·ä»Žæ‰˜ç›˜å›¾æ ‡ç§»åŠ¨åˆ°èœå•ä¸Š
        popupWatchdog.setInitialDelay(600);
        popupWatchdog.start();
    }
    /**
     * å…œåº•检查:光标既不在菜单上、也不在托盘图标附近时,连续两次就收起菜单
     */
    private void checkPopupDismiss() {
        JWindow window = popupWindow;
        if (window == null || !window.isVisible()) {
            stopPopupWatchdog();
            return;
        }
        Point mouse = MouseInfo.getPointerInfo() == null ? null : MouseInfo.getPointerInfo().getLocation();
        if (mouse == null) {
            return;
        }
        boolean nearMenu = window.getBounds().contains(mouse);
        boolean nearAnchor = popupAnchor != null && popupAnchor.distance(mouse) < 80;
        if (nearMenu || nearAnchor) {
            outsideCount = 0;
            return;
        }
        if (++outsideCount >= 2) {
            hidePopupWindow();
        }
    }
    private void stopPopupWatchdog() {
        if (popupWatchdog != null) {
            popupWatchdog.stop();
            popupWatchdog = null;
        }
        outsideCount = 0;
    }
    /**
     * èœå•窗口按屏幕创建:多显示器时创建在光标所在的那块屏幕上
     */
    private JWindow ensurePopupWindow(Point mouse) {
        GraphicsConfiguration configuration = screenConfigurationOf(mouse);
        if (popupWindow == null || popupWindow.getGraphicsConfiguration() != configuration) {
            if (popupWindow != null) {
                popupWindow.dispose();
            }
            popupWindow = createPopupWindow(configuration);
        }
        return popupWindow;
    }
    /**
     * åˆ›å»ºèœå•窗口:无边框、置顶、可获取焦点(点别处失焦就能自动收起)
     */
    private JWindow createPopupWindow(GraphicsConfiguration configuration) {
        Font menuFont = resolveMenuFont();
        JPanel panel = new JPanel();
        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
        panel.setBackground(MENU_BACKGROUND);
        panel.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(MENU_BORDER),
                BorderFactory.createEmptyBorder(4, 0, 4, 0)));
        panel.add(createMenuItem("查看串口状态", menuFont, e -> showStatusDialog()));
        panel.add(createMenuItem("关闭串口监听", menuFont, e -> closeSerialPort()));
        panel.add(createMenuItem("打开日志目录", menuFont, e -> openLogDirectory()));
        panel.add(createSeparator());
        panel.add(createMenuItem("退出", menuFont, e -> exit()));
        // æŒ‰ ESC æ”¶èµ·èœå•
        panel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
                .put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "hidePopupWindow");
        panel.getActionMap().put("hidePopupWindow", new AbstractAction() {
            private static final long serialVersionUID = 1L;
            @Override
            public void actionPerformed(ActionEvent event) {
                hidePopupWindow();
            }
        });
        JWindow window = new JWindow(configuration);
        window.setContentPane(panel);
        window.setAlwaysOnTop(true);
        window.setFocusable(true);
        // å…³é”®ï¼šçª—口必须能获得焦点,点菜单外面时才会触发失焦、菜单才会自动收起
        window.setFocusableWindowState(true);
        window.addWindowFocusListener(new WindowAdapter() {
            @Override
            public void windowLostFocus(WindowEvent event) {
                hidePopupWindow();
            }
        });
        window.addWindowListener(new WindowAdapter() {
            @Override
            public void windowDeactivated(WindowEvent event) {
                hidePopupWindow();
            }
        });
        window.pack();
        return window;
    }
    /**
     * èœå•项:扁平按钮 + æ‚¬åœé«˜äº®
     */
    private JButton createMenuItem(String text, Font font, ActionListener actionListener) {
        JButton item = new JButton(text);
        item.setFont(font);
        item.setHorizontalAlignment(SwingConstants.LEFT);
        item.setBorder(BorderFactory.createEmptyBorder(6, 18, 6, 24));
        item.setBorderPainted(false);
        item.setFocusPainted(false);
        item.setContentAreaFilled(false);
        item.setOpaque(true);
        item.setBackground(MENU_BACKGROUND);
        item.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
        item.setMaximumSize(new Dimension(Integer.MAX_VALUE, item.getPreferredSize().height));
        item.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseEntered(MouseEvent event) {
                item.setBackground(MENU_HOVER_BACKGROUND);
            }
            @Override
            public void mouseExited(MouseEvent event) {
                item.setBackground(MENU_BACKGROUND);
            }
        });
        item.addActionListener(event -> {
            // å…ˆæ”¶èµ·èœå•,再执行动作
            hidePopupWindow();
            actionListener.actionPerformed(event);
        });
        return item;
    }
    /**
     * èœå•分隔线
     */
    private JSeparator createSeparator() {
        JSeparator separator = new JSeparator();
        separator.setForeground(MENU_BORDER);
        separator.setMaximumSize(new Dimension(Integer.MAX_VALUE, 1));
        return separator;
    }
    /**
     * @return å…‰æ ‡æ‰€åœ¨å±å¹•的显示配置,找不到就用主屏幕
     */
    private GraphicsConfiguration screenConfigurationOf(Point point) {
        GraphicsEnvironment environment = GraphicsEnvironment.getLocalGraphicsEnvironment();
        for (GraphicsDevice device : environment.getScreenDevices()) {
            GraphicsConfiguration configuration = device.getDefaultConfiguration();
            if (configuration.getBounds().contains(point)) {
                return configuration;
            }
        }
        return environment.getDefaultScreenDevice().getDefaultConfiguration();
    }
    /**
     * é€€å‡ºç¨‹åºï¼šå…ˆå…³é—­ Spring å®¹å™¨ï¼ˆè§¦å‘ @PreDestroy,关闭串口、停止补帧线程),再结束进程
     */
    private void exit() {
        log.info("从系统托盘退出程序");
        removeTrayIcon();
        int exitCode = SpringApplication.exit(applicationContext, () -> 0);
        System.exit(exitCode);
    }
    /**
     * é¡¹ç›®åœæ­¢æ—¶ç§»é™¤æ‰˜ç›˜å›¾æ ‡ã€é”€æ¯èœå•窗口
     */
    @PreDestroy
    public void destroy() {
        removeTrayIcon();
        stopPopupWatchdog();
        stopStatusRefreshTimer();
        if (statusDialog != null) {
            statusDialog.dispose();
            statusDialog = null;
        }
        if (popupWindow != null) {
            popupWindow.dispose();
            popupWindow = null;
        }
    }
    private void removeTrayIcon() {
        TrayIcon icon = trayIcon;
        trayIcon = null;
        if (icon != null) {
            SystemTray.getSystemTray().remove(icon);
        }
    }
    /**
     * å¼¹å‡ºé‡‡é›†çŠ¶æ€çª—å£ï¼ˆä¸æ˜¯æ‰“å¼€æµè§ˆå™¨ï¼‰ï¼Œçª—å£é‡Œçš„çŠ¶æ€æ¯ç§’è‡ªåŠ¨åˆ·æ–°
     */
    private void showStatusDialog() {
        if (statusDialog == null) {
            statusDialog = createStatusDialog();
        }
        refreshStatus();
        statusDialog.setLocationRelativeTo(null);
        statusDialog.setVisible(true);
        statusDialog.toFront();
    }
    /**
     * åˆ›å»ºä¸²å£çŠ¶æ€çª—å£ï¼šä¸²å£åç§° / ç›‘听状态 / ä¸²å£è¿žæŽ¥ / ç¼“存条数 + åˆ·æ–°ã€å…³é—­æŒ‰é’®
     */
    private JDialog createStatusDialog() {
        Font font = resolveMenuFont();
        JPanel fields = new JPanel(new GridLayout(4, 2, 12, 10));
        fields.setBackground(MENU_BACKGROUND);
        serialPortNameValue = addStatusRow(fields, "串口名称", font);
        listeningValue = addStatusRow(fields, "监听状态", font);
        portOpenValue = addStatusRow(fields, "串口连接", font);
        dataSizeValue = addStatusRow(fields, "缓存数据", font);
        JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
        buttons.setBackground(MENU_BACKGROUND);
        JButton refreshButton = new JButton("刷新");
        refreshButton.setFont(font);
        refreshButton.addActionListener(event -> refreshStatus());
        JButton closeButton = new JButton("关闭");
        closeButton.setFont(font);
        closeButton.addActionListener(event -> statusDialog.setVisible(false));
        buttons.add(refreshButton);
        buttons.add(closeButton);
        JPanel content = new JPanel(new BorderLayout(0, 14));
        content.setBackground(MENU_BACKGROUND);
        content.setBorder(BorderFactory.createEmptyBorder(18, 22, 14, 22));
        content.add(fields, BorderLayout.CENTER);
        content.add(buttons, BorderLayout.SOUTH);
        JDialog dialog = new JDialog();
        dialog.setTitle("串口状态");
        dialog.setModal(false);
        dialog.setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE);
        dialog.setContentPane(content);
        dialog.addWindowListener(new WindowAdapter() {
            @Override
            public void windowOpened(WindowEvent event) {
                refreshStatus();
                startStatusRefreshTimer();
            }
            @Override
            public void windowClosing(WindowEvent event) {
                // å…³é—­ï¼ˆå…¶å®žæ˜¯éšè—ï¼‰æ—¶åœæŽ‰è‡ªåŠ¨åˆ·æ–°
                stopStatusRefreshTimer();
            }
        });
        dialog.pack();
        dialog.setLocationRelativeTo(null);
        return dialog;
    }
    /**
     * çŠ¶æ€çª—å£é‡Œçš„ä¸€è¡Œï¼šå·¦è¾¹æ˜¯æ ‡é¢˜ï¼Œå³è¾¹æ˜¯å€¼
     *
     * @return å€¼é‚£ä¸€åˆ—的标签,刷新时直接 setText
     */
    private JLabel addStatusRow(JPanel parent, String title, Font font) {
        JLabel titleLabel = new JLabel(title + ":", SwingConstants.RIGHT);
        titleLabel.setFont(font);
        titleLabel.setForeground(new Color(0x666666));
        JLabel valueLabel = new JLabel("-");
        valueLabel.setFont(font);
        valueLabel.setForeground(new Color(0x222222));
        parent.add(titleLabel);
        parent.add(valueLabel);
        return valueLabel;
    }
    /**
     * åˆ·æ–°çŠ¶æ€çª—å£é‡Œçš„å†…å®¹
     */
    private void refreshStatus() {
        if (serialPortNameValue == null) {
            return;
        }
        SerialPortListener listener = serialPortListener();
        if (listener == null) {
            serialPortNameValue.setText("-");
            listeningValue.setText("获取串口监听器失败");
            portOpenValue.setText("-");
            dataSizeValue.setText("-");
            return;
        }
        String serialPortName = listener.getListenName();
        serialPortNameValue.setText(StringUtils.hasText(serialPortName) ? serialPortName : "-");
        listeningValue.setText(listener.isListening() ? "监听中" : "未监听");
        portOpenValue.setText(listener.isPortOpen() ? "已打开" : "未打开");
        dataSizeValue.setText(listener.dataSize() + " æ¡");
    }
    /**
     * @return ä¸²å£ç›‘听器,取不到时返回 null
     */
    private SerialPortListener serialPortListener() {
        if (applicationContext == null) {
            return null;
        }
        try {
            return applicationContext.getBean(SerialPortListener.class);
        } catch (Exception e) {
            log.warn("获取串口监听器失败:{}", e.getMessage());
            return null;
        }
    }
    private void startStatusRefreshTimer() {
        if (statusRefreshTimer == null) {
            statusRefreshTimer = new Timer(1000, event -> refreshStatus());
        }
        statusRefreshTimer.start();
    }
    private void stopStatusRefreshTimer() {
        if (statusRefreshTimer != null) {
            statusRefreshTimer.stop();
        }
    }
    /**
     * å…³é—­ä¸²å£ç›‘听(只关串口,不退出程序),方便现场直接把串口释放给别的程序用
     */
    private void closeSerialPort() {
        SerialPortListener listener = serialPortListener();
        if (listener == null) {
            showError("获取串口监听器失败,无法关闭串口");
            return;
        }
        String serialPortName = listener.getListenName();
        serialPortName = StringUtils.hasText(serialPortName) ? serialPortName : "-";
        if (!listener.isListening() && !listener.isPortOpen()) {
            notifyMessage("串口 " + serialPortName + " å½“前未开启监听");
            return;
        }
        if (listener.closeListening()) {
            log.info("从系统托盘关闭串口 {} ç›‘听", serialPortName);
            notifyMessage("串口 " + serialPortName + " ç›‘听已关闭");
        } else {
            showError("串口 " + serialPortName + " å…³é—­å¤±è´¥ï¼Œè¯·æ£€æŸ¥ä¸²å£çŠ¶æ€");
        }
    }
    /**
     * æ‰˜ç›˜æ°”泡提示,不打断当前操作
     */
    private void notifyMessage(String message) {
        if (trayIcon == null) {
            log.info(message);
            return;
        }
        trayIcon.displayMessage("亨旺特导MES数据采集器", message, TrayIcon.MessageType.INFO);
    }
    /**
     * å‡ºé”™æ—¶å¼¹çª—提示(气泡提示可能被系统设置屏蔽,失败必须让人看见)
     */
    private void showError(String message) {
        log.warn(message);
        try {
            JOptionPane.showMessageDialog(null, message, "数据采集器", JOptionPane.ERROR_MESSAGE);
        } catch (Exception e) {
            log.warn("弹出错误提示失败:{}", e.getMessage());
        }
    }
    /**
     * æ‰“开日志目录,方便现场排查问题
     */
    private void openLogDirectory() {
        try {
            File directory = new File(StringUtils.hasText(logLocation) ? logLocation : "logs");
            if (!directory.exists() && !directory.mkdirs()) {
                log.warn("日志目录不存在且创建失败:{}", directory.getAbsolutePath());
            }
            Desktop.getDesktop().open(directory);
        } catch (Exception e) {
            log.warn("打开日志目录失败:{}", e.getMessage());
        }
    }
    /**
     * èœå•字体:优先挑一个系统里存在的中文字体
     *
     * @return èœå•字体
     */
    private Font resolveMenuFont() {
        Set<String> fontFamilies = new HashSet<>(Arrays.asList(
                GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()));
        for (String name : new String[]{"Microsoft YaHei", "微软雅黑", "SimSun", "宋体"}) {
            if (fontFamilies.contains(name)) {
                return new Font(name, Font.PLAIN, 12);
            }
        }
        return new Font(Font.DIALOG, Font.PLAIN, 12);
    }
    /**
     * æ‰˜ç›˜å›¾æ ‡ï¼šä¼˜å…ˆç”¨ classpath ä¸‹çš„ tray.png,没有就现画一个,避免少一个图片文件就跑不起来
     */
    private Image loadTrayImage() {
        try (InputStream in = getClass().getClassLoader().getResourceAsStream("tray.png")) {
            if (in != null) {
                BufferedImage image = ImageIO.read(in);
                if (image != null) {
                    return image;
                }
            }
        } catch (Exception e) {
            log.warn("读取托盘图标 tray.png å¤±è´¥ï¼Œæ”¹ç”¨é»˜è®¤å›¾æ ‡ï¼š{}", e.getMessage());
        }
        return createDefaultTrayImage();
    }
    /**
     * é»˜è®¤å›¾æ ‡ï¼šè“åº•白字 S
     */
    private Image createDefaultTrayImage() {
        BufferedImage image = new BufferedImage(DEFAULT_ICON_SIZE, DEFAULT_ICON_SIZE, BufferedImage.TYPE_INT_ARGB);
        Graphics2D graphics = image.createGraphics();
        try {
            graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            graphics.setColor(new Color(0x2D8CF0));
            graphics.fillRoundRect(0, 0, DEFAULT_ICON_SIZE, DEFAULT_ICON_SIZE, 8, 8);
            graphics.setColor(Color.WHITE);
            graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 20));
            graphics.drawString("S", 10, 24);
        } finally {
            graphics.dispose();
        }
        return image;
    }
}
src/main/java/com/hwtd/mes/collect/handler/SerialPortListener.java
@@ -425,7 +425,7 @@
                    log.debug("串口 {} ç¼“存已满 {} æ¡ï¼Œç§»é™¤æœ€æ—§çš„一条数据 {}",
                            setting.getSerialPortName(), setting.getMaxCount(), removed);
                }
                log.info("串口 {} æŽ¥æ”¶åˆ°æ•°æ®-->{}", setting.getSerialPortName(), value);
//                log.info("串口 {} æŽ¥æ”¶åˆ°æ•°æ®-->{}", setting.getSerialPortName(), value);
            }
        }
    }
src/main/resources/tray.png