1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
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;
    }
}