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)启动后在任务栏右下角托盘常驻一个小图标,右键菜单可以快速退出。 *

* 说明: *

*/ @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 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; } }