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)启动后在任务栏右下角托盘常驻一个小图标,右键菜单可以快速退出。
*
* 说明:
*
* - 托盘依赖 AWT,而 Spring Boot 默认 {@code java.awt.headless=true}(此时 {@link SystemTray#isSupported()} 为 false),
* 所以启动类里已经用 {@code setHeadless(false)} 关掉;
* - 右键菜单用 Swing 自己画一个无边框窗口({@link JWindow}),不用 AWT 的 PopupMenu:
* AWT 菜单是 Windows 原生菜单,文字用系统菜单字体绘制,部分机器上不含中文字形会显示成方块,而且 {@code setFont} 不生效;
* - 菜单弹出位置和系统托盘菜单一致:显示在光标上方、右边缘与光标对齐,并保证不超出屏幕(多显示器时按光标所在屏幕计算);
* - 菜单收起方式:点击菜单外面(窗口失焦)、按 ESC、再右键一次、点了某一项之后,
* 另外还有一层兜底——鼠标离开菜单附近也会自动收起(避免个别环境窗口抢不到焦点导致菜单关不掉);
* - 菜单里的“查看串口状态”(或双击托盘图标)会弹出一个状态窗口:串口名称、监听状态、串口连接、缓存条数,每秒自动刷新;
* - 菜单里的“关闭串口”只关闭串口监听(不退出程序),方便现场把串口释放给别的程序用,结果用托盘气泡提示;
* - 图标优先读取 classpath 下的 {@code tray.png}(放一张 32x32 的 png 即可换成自己的图标),没有就用代码画的默认图标;
* - 点“退出”会先关闭 Spring 容器(触发 {@code @PreDestroy},例如关闭串口监听、停止补帧线程),再结束进程;
* 用任务管理器结束进程、或直接关掉 exe 的控制台窗口,则不会走这套清理流程;
* - 以 Windows 服务(Session 0,无桌面)方式运行时托盘不会显示,代码会自动跳过,不影响接口使用。
*
*/
@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;
}
}