wangxichen 2 gün önce
ebeveyn
işleme
bb8fa27f19

BIN
config/liuzhuanka_template.pdf


BIN
lib/mes-print.jar


+ 3 - 0
logs/mes-client-2026-07-28.log

@@ -0,0 +1,3 @@
+[2026-07-28 11:16:48.692][WARN][mes-log-shutdown] 客户端退出时未收到正常关闭标记
+[2026-07-28 14:26:10.644][WARN][main] 检测到上一次客户端未正常关闭,可能是强制结束、崩溃、断电或系统终止
+[2026-07-28 14:26:14.984][WARN][mes-log-shutdown] 客户端退出时未收到正常关闭标记

+ 2 - 0
logs/mes-client-2026-07-31.log

@@ -0,0 +1,2 @@
+[2026-07-31 14:52:37.062][WARN][main] 检测到上一次客户端未正常关闭,可能是强制结束、崩溃、断电或系统终止
+[2026-07-31 14:52:41.993][WARN][mes-log-shutdown] 客户端退出时未收到正常关闭标记

+ 2 - 0
mes-client.running

@@ -0,0 +1,2 @@
+pid=21308
+heartbeat=2026-07-31 14:52:37.080

+ 3 - 0
src/META-INF/MANIFEST.MF

@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Main-Class: com.mes.ui.MesClient
+

+ 431 - 0
src/com/mes/print/CoordinateAdjustTool.java

@@ -0,0 +1,431 @@
+package com.mes.print;
+
+import com.mes.print.model.FlowCardData;
+import com.mes.print.template.FlowCardRenderer;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+
+/**
+ * 流转卡坐标调整工具
+ * 可视化调整二维码和文字位置(支持项目号、钢印码、客户码)
+ */
+public class CoordinateAdjustTool extends JFrame {
+
+    // 当前坐标(初始值从 FlowCardRenderer 获取)
+    private float qrX = 194.5f;
+    private float qrY = 65.4f - 16.0f;  // 已上移
+    private float qrW = 35.7f;
+    private float qrH = 34.6f;
+
+    private float projTextX = 291.5f;
+    private float projTextY = 75.0f - 16.0f;  // 项目号
+    private float steelTextX = 291.5f;
+    private float steelTextY = 85.5f - 16.0f;  // 钢印码
+    private float snTextX = 291.5f;
+    private float snTextY = 96.4f - 16.0f;     // 客户码
+
+    private JLabel previewLabel;
+    private FlowCardData testData;
+
+    // 拖拽相关
+    private boolean draggingQr = false;
+    private Point dragStart = null;
+
+    public CoordinateAdjustTool() {
+        setTitle("流转卡坐标调整工具 - OP170");
+        setSize(1200, 900);
+        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+        setLayout(new BorderLayout(10, 10));
+
+        // 测试数据
+        testData = new FlowCardData();
+        testData.setSn("+KB60IS3031T729002101");
+        testData.setSteelSn("T09260729002");
+
+        // 左侧:预览区域
+        previewLabel = new JLabel();
+        previewLabel.setHorizontalAlignment(JLabel.CENTER);
+
+        // 添加鼠标拖拽监听
+        previewLabel.addMouseListener(new java.awt.event.MouseAdapter() {
+            @Override
+            public void mousePressed(java.awt.event.MouseEvent e) {
+                handleMousePressed(e);
+            }
+            @Override
+            public void mouseReleased(java.awt.event.MouseEvent e) {
+                draggingQr = false;
+                dragStart = null;
+            }
+        });
+        previewLabel.addMouseMotionListener(new java.awt.event.MouseMotionAdapter() {
+            @Override
+            public void mouseDragged(java.awt.event.MouseEvent e) {
+                handleMouseDragged(e);
+            }
+        });
+
+        JScrollPane scrollPane = new JScrollPane(previewLabel);
+        add(scrollPane, BorderLayout.CENTER);
+
+        // 右侧:控制面板
+        JPanel controlPanel = new JPanel();
+        controlPanel.setLayout(new BoxLayout(controlPanel, BoxLayout.Y_AXIS));
+        controlPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+        // 输入框区域
+        controlPanel.add(createLabel("=== 测试数据 ==="));
+        JTextField snField = new JTextField(testData.getSn(), 20);
+        JTextField steelSnField = new JTextField(testData.getSteelSn(), 15);
+        JPanel inputPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+        inputPanel.add(new JLabel("客户码:"));
+        inputPanel.add(snField);
+        controlPanel.add(inputPanel);
+        JPanel inputPanel2 = new JPanel(new FlowLayout(FlowLayout.LEFT));
+        inputPanel2.add(new JLabel("钢印码:"));
+        inputPanel2.add(steelSnField);
+        controlPanel.add(inputPanel2);
+
+        // 项目号显示
+        JLabel projLabel = new JLabel("项目号: " + testData.getProdCode());
+        projLabel.setFont(new Font("Microsoft YaHei UI", Font.BOLD, 12));
+        projLabel.setForeground(new Color(0, 100, 0));
+        controlPanel.add(projLabel);
+
+        JButton updateBtn = new JButton("更新预览");
+        updateBtn.addActionListener(e -> {
+            testData.setSn(snField.getText());
+            testData.setSteelSn(steelSnField.getText());
+            projLabel.setText("项目号: " + testData.getProdCode());
+            updatePreview();
+        });
+        controlPanel.add(updateBtn);
+        controlPanel.add(Box.createVerticalStrut(20));
+
+        // 二维码坐标控制
+        controlPanel.add(createLabel("=== 二维码位置 ==="));
+        JTextField qrXField = new JTextField(String.format("%.1f", qrX), 8);
+        JTextField qrYField = new JTextField(String.format("%.1f", qrY), 8);
+
+        controlPanel.add(createCoordinatePanel("X", qrXField, qrX, 100f, 300f, v -> {
+            qrX = v;
+            qrXField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+
+        controlPanel.add(createCoordinatePanel("Y", qrYField, qrY, 30f, 150f, v -> {
+            qrY = v;
+            qrYField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+        controlPanel.add(Box.createVerticalStrut(20));
+
+        // 项目号坐标控制
+        controlPanel.add(createLabel("=== 项目号位置 ==="));
+        JTextField projXField = new JTextField(String.format("%.1f", projTextX), 8);
+        JTextField projYField = new JTextField(String.format("%.1f", projTextY), 8);
+
+        controlPanel.add(createCoordinatePanel("X", projXField, projTextX, 200f, 400f, v -> {
+            projTextX = v;
+            projXField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+
+        controlPanel.add(createCoordinatePanel("Y", projYField, projTextY, 50f, 150f, v -> {
+            projTextY = v;
+            projYField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+        controlPanel.add(Box.createVerticalStrut(20));
+
+        // 钢印码坐标控制
+        controlPanel.add(createLabel("=== 钢印码位置 ==="));
+        JTextField steelXField = new JTextField(String.format("%.1f", steelTextX), 8);
+        JTextField steelYField = new JTextField(String.format("%.1f", steelTextY), 8);
+
+        controlPanel.add(createCoordinatePanel("X", steelXField, steelTextX, 200f, 400f, v -> {
+            steelTextX = v;
+            steelXField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+
+        controlPanel.add(createCoordinatePanel("Y", steelYField, steelTextY, 50f, 150f, v -> {
+            steelTextY = v;
+            steelYField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+        controlPanel.add(Box.createVerticalStrut(20));
+
+        // 客户码坐标控制
+        controlPanel.add(createLabel("=== 客户码位置 ==="));
+        JTextField snXField = new JTextField(String.format("%.1f", snTextX), 8);
+        JTextField snYField = new JTextField(String.format("%.1f", snTextY), 8);
+
+        controlPanel.add(createCoordinatePanel("X", snXField, snTextX, 200f, 400f, v -> {
+            snTextX = v;
+            snXField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+
+        controlPanel.add(createCoordinatePanel("Y", snYField, snTextY, 50f, 150f, v -> {
+            snTextY = v;
+            snYField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+        controlPanel.add(Box.createVerticalStrut(20));
+
+        // 按钮
+        JButton exportBtn = new JButton("生成代码");
+        exportBtn.setFont(new Font("Microsoft YaHei UI", Font.BOLD, 14));
+        exportBtn.addActionListener(e -> exportCode());
+        controlPanel.add(exportBtn);
+
+        JButton previewPdfBtn = new JButton("预览 PDF");
+        previewPdfBtn.setFont(new Font("Microsoft YaHei UI", Font.BOLD, 14));
+        previewPdfBtn.addActionListener(e -> previewPdf());
+        controlPanel.add(previewPdfBtn);
+
+        add(controlPanel, BorderLayout.EAST);
+
+        // 初始预览
+        updatePreview();
+    }
+
+    private void handleMousePressed(java.awt.event.MouseEvent e) {
+        if (previewLabel.getIcon() == null) return;
+
+        ImageIcon icon = (ImageIcon) previewLabel.getIcon();
+        int imgW = icon.getIconWidth();
+        int imgH = icon.getIconHeight();
+        int labelW = previewLabel.getWidth();
+        int labelH = previewLabel.getHeight();
+
+        int offsetX = (labelW - imgW) / 2;
+        int offsetY = (labelH - imgH) / 2;
+
+        int clickX = e.getX() - offsetX;
+        int clickY = e.getY() - offsetY;
+
+        if (clickX < 0 || clickX >= imgW || clickY < 0 || clickY >= imgH) return;
+
+        double scale = 300.0 / 72.0;
+        int qrImgX = (int) (qrX * scale);
+        int qrImgY = (int) (qrY * scale);
+        int qrImgW = (int) (qrW * scale);
+        int qrImgH = (int) (qrH * scale);
+
+        if (clickX >= qrImgX && clickX <= qrImgX + qrImgW &&
+            clickY >= qrImgY && clickY <= qrImgY + qrImgH) {
+            draggingQr = true;
+            dragStart = new Point(clickX, clickY);
+        }
+    }
+
+    private void handleMouseDragged(java.awt.event.MouseEvent e) {
+        if (!draggingQr || dragStart == null) return;
+
+        if (previewLabel.getIcon() == null) return;
+
+        ImageIcon icon = (ImageIcon) previewLabel.getIcon();
+        int imgW = icon.getIconWidth();
+        int imgH = icon.getIconHeight();
+        int labelW = previewLabel.getWidth();
+        int labelH = previewLabel.getHeight();
+
+        int offsetX = (labelW - imgW) / 2;
+        int offsetY = (labelH - imgH) / 2;
+
+        int currentX = e.getX() - offsetX;
+        int currentY = e.getY() - offsetY;
+
+        int deltaX = currentX - dragStart.x;
+        int deltaY = currentY - dragStart.y;
+
+        double scale = 300.0 / 72.0;
+        float pdfDeltaX = (float) (deltaX / scale);
+        float pdfDeltaY = (float) (deltaY / scale);
+
+        qrX += pdfDeltaX;
+        qrY += pdfDeltaY;
+
+        dragStart = new Point(currentX, currentY);
+
+        updatePreview();
+    }
+
+    private JLabel createLabel(String text) {
+        JLabel label = new JLabel(text);
+        label.setFont(new Font("Microsoft YaHei UI", Font.BOLD, 14));
+        return label;
+    }
+
+    private JPanel createCoordinatePanel(String label, JTextField field, float initValue, float min, float max, ValueChangeListener listener) {
+        JPanel panel = new JPanel(new BorderLayout(5, 5));
+
+        JLabel nameLabel = new JLabel(label);
+        nameLabel.setFont(new Font("Microsoft YaHei UI", Font.PLAIN, 12));
+
+        JLabel valueLabel = new JLabel(String.format("%.1f", initValue));
+        valueLabel.setFont(new Font("Consolas", Font.PLAIN, 12));
+        valueLabel.setPreferredSize(new Dimension(50, 20));
+
+        JSlider slider = new JSlider(JSlider.HORIZONTAL, (int)(min * 10), (int)(max * 10), (int)(initValue * 10));
+        slider.addChangeListener(e -> {
+            float value = slider.getValue() / 10.0f;
+            valueLabel.setText(String.format("%.1f", value));
+            listener.onValueChange(value);
+        });
+
+        field.addActionListener(e -> {
+            try {
+                float value = Float.parseFloat(field.getText());
+                slider.setValue((int)(value * 10));
+                listener.onValueChange(value);
+            } catch (NumberFormatException ex) {
+                field.setText(String.format("%.1f", initValue));
+            }
+        });
+
+        JPanel topPanel = new JPanel(new BorderLayout());
+        topPanel.add(nameLabel, BorderLayout.WEST);
+        topPanel.add(field, BorderLayout.CENTER);
+        topPanel.add(valueLabel, BorderLayout.EAST);
+
+        panel.add(topPanel, BorderLayout.NORTH);
+        panel.add(slider, BorderLayout.CENTER);
+
+        return panel;
+    }
+
+    private void updatePreview() {
+        try {
+            BufferedImage preview = createPreviewImage();
+            ImageIcon icon = new ImageIcon(preview);
+            previewLabel.setIcon(icon);
+        } catch (Exception e) {
+            e.printStackTrace();
+            JOptionPane.showMessageDialog(this, "预览失败: " + e.getMessage());
+        }
+    }
+
+    private BufferedImage createPreviewImage() throws Exception {
+        BufferedImage templateBg = FlowCardRenderer.getTemplateImage();
+
+        BufferedImage img = new BufferedImage(templateBg.getWidth(), templateBg.getHeight(), BufferedImage.TYPE_INT_RGB);
+        Graphics2D g2 = img.createGraphics();
+        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+        g2.drawImage(templateBg, 0, 0, null);
+
+        double scale = 300.0 / 72.0;
+
+        // 绘制二维码
+        try {
+            BufferedImage qrCode = FlowCardRenderer.generateQrCode(testData.getSn(), 600);
+            int imgQrX = (int)(qrX * scale);
+            int imgQrY = (int)(qrY * scale);
+            int imgQrW = (int)(qrW * scale);
+            int imgQrH = (int)(qrH * scale);
+            g2.drawImage(qrCode, imgQrX, imgQrY, imgQrW, imgQrH, null);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+
+        g2.setColor(Color.BLACK);
+        g2.setFont(new Font("SimSun", Font.BOLD, (int)(8 * scale)));
+
+        // 绘制项目号(蓝色)
+        String projCode = testData.getProdCode();
+        if (projCode != null && !projCode.isEmpty()) {
+            int imgProjX = (int)(projTextX * scale);
+            int imgProjY = (int)(projTextY * scale);
+            g2.setColor(new Color(0, 0, 200));
+            g2.drawString(projCode, imgProjX, imgProjY);
+            // 蓝色十字标记
+            g2.setStroke(new BasicStroke(2));
+            g2.drawLine(imgProjX - 20, imgProjY, imgProjX + 20, imgProjY);
+            g2.drawLine(imgProjX, imgProjY - 20, imgProjX, imgProjY + 20);
+        }
+
+        // 绘制钢印码(红色)
+        int imgSteelX = (int)(steelTextX * scale);
+        int imgSteelY = (int)(steelTextY * scale);
+        if (testData.getSteelSn() != null) {
+            g2.setColor(Color.BLACK);
+            g2.drawString(testData.getSteelSn(), imgSteelX, imgSteelY);
+        }
+        g2.setColor(Color.RED);
+        g2.setStroke(new BasicStroke(2));
+        g2.drawLine(imgSteelX - 20, imgSteelY, imgSteelX + 20, imgSteelY);
+        g2.drawLine(imgSteelX, imgSteelY - 20, imgSteelX, imgSteelY + 20);
+
+        // 绘制客户码(绿色)
+        int imgSnX = (int)(snTextX * scale);
+        int imgSnY = (int)(snTextY * scale);
+        if (testData.getSn() != null) {
+            g2.setColor(Color.BLACK);
+            g2.drawString(testData.getSn(), imgSnX, imgSnY);
+        }
+        g2.setColor(Color.GREEN.darker());
+        g2.setStroke(new BasicStroke(2));
+        g2.drawLine(imgSnX - 20, imgSnY, imgSnX + 20, imgSnY);
+        g2.drawLine(imgSnX, imgSnY - 20, imgSnX, imgSnY + 20);
+
+        // 显示当前坐标
+        g2.setColor(Color.BLACK);
+        g2.setFont(new Font("Consolas", Font.BOLD, 24));
+        g2.drawString(String.format("QR: (%.1f, %.1f)", qrX, qrY), 20, 50);
+        g2.drawString(String.format("项目: (%.1f, %.1f)", projTextX, projTextY), 20, 90);
+        g2.drawString(String.format("钢印: (%.1f, %.1f)", steelTextX, steelTextY), 20, 130);
+        g2.drawString(String.format("客户: (%.1f, %.1f)", snTextX, snTextY), 20, 170);
+
+        g2.dispose();
+        return img;
+    }
+
+    private void exportCode() {
+        String code = String.format(
+            "// 坐标配置(复制到 FlowCardRenderer.java 中)\n" +
+            "private static final float QR_X = %.1ff;\n" +
+            "private static final float QR_Y = %.1ff;\n" +
+            "private static final float QR_W = %.1ff;\n" +
+            "private static final float QR_H = %.1ff;\n\n" +
+            "private static final float PROJ_TEXT_X = %.1ff;\n" +
+            "private static final float PROJ_TEXT_Y = %.1ff;\n" +
+            "private static final float STEEL_TEXT_X = %.1ff;\n" +
+            "private static final float STEEL_TEXT_Y = %.1ff;\n" +
+            "private static final float SN_TEXT_X = %.1ff;\n" +
+            "private static final float SN_TEXT_Y = %.1ff;\n",
+            qrX, qrY, qrW, qrH,
+            projTextX, projTextY, steelTextX, steelTextY, snTextX, snTextY
+        );
+
+        JTextArea textArea = new JTextArea(code, 15, 50);
+        textArea.setFont(new Font("Consolas", Font.PLAIN, 12));
+        textArea.setEditable(false);
+        JOptionPane.showMessageDialog(this, new JScrollPane(textArea), "生成的代码", JOptionPane.INFORMATION_MESSAGE);
+    }
+
+    private void previewPdf() {
+        try {
+            FlowCardRenderer.preview(testData);
+            JOptionPane.showMessageDialog(this, "PDF 预览已打开");
+        } catch (Exception e) {
+            JOptionPane.showMessageDialog(this, "预览失败: " + e.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
+        }
+    }
+
+    interface ValueChangeListener {
+        void onValueChange(float value);
+    }
+
+    public static void main(String[] args) {
+        SwingUtilities.invokeLater(() -> {
+            CoordinateAdjustTool tool = new CoordinateAdjustTool();
+            tool.setVisible(true);
+        });
+    }
+}

+ 100 - 0
src/com/mes/print/Printer.java

@@ -0,0 +1,100 @@
+package com.mes.print;
+
+import java.util.List;
+
+import com.mes.print.model.FlowCardData;
+import com.mes.print.model.LabelData;
+import com.mes.print.util.PrinterUtil;
+
+/**
+ * 对外唯一入口。业务代码只需调这里的静态方法。
+ *
+ * 使用示例:
+ * <pre>
+ *   FlowCardData d = new FlowCardData();
+ *   d.setSn("A2026072200001");
+ *   d.setProduct("+KB24");
+ *   Printer.printFlowCard(d);
+ *
+ *   LabelData l = new LabelData();
+ *   l.setSn("A2026072200001");
+ *   l.setBarcode("A2026072200001");
+ *   Printer.printLabel(l);
+ * </pre>
+ *
+ * 配置文件 config/print.properties 示例:
+ * <pre>
+ *   printer.type=hp        # 或 tsc
+ *   printer.name=HP LaserJet 3004
+ * </pre>
+ */
+public class Printer {
+
+	private Printer() {}
+
+	/** 打印流转卡(A4,走 HP 类打印机) */
+	public static void printFlowCard(FlowCardData data) {
+		PrinterFactory.get().printFlowCard(data);
+	}
+
+	/** 预览流转卡(生成临时 PDF 并用系统默认程序打开) */
+	public static void previewFlowCard(FlowCardData data) {
+		com.mes.print.template.FlowCardRenderer.preview(data);
+	}
+
+	/** 打印标签(小尺寸,走 TSC 类打印机) */
+	public static void printLabel(LabelData data) {
+		PrinterFactory.get().printLabel(data);
+	}
+
+	/** 列出本机所有识别到的打印机(装机排错用) */
+	public static List<String> listAvailablePrinters() {
+		return PrinterUtil.listNames();
+	}
+
+	/** 重新加载配置(配置改动后调用) */
+	public static void reload() {
+		com.mes.print.config.PrinterConfig.reset();
+		PrinterFactory.reset();
+	}
+
+	/**
+	 * 便捷方法:保存打印机配置到 config/print.properties 并立即生效。
+	 * @param type hp / tsc / none
+	 * @param name Windows 打印机名字(none 时可传 null)
+	 */
+	public static void setPrinter(String type, String name) {
+		com.mes.print.config.PrinterConfig.save(type, name);
+		PrinterFactory.reset();
+	}
+
+	/** 当前配置的打印机类型(hp / tsc / none,读不到时返回 none) */
+	public static String getCurrentType() {
+		String t = com.mes.print.config.PrinterConfig.get("printer.type", "none");
+		return t == null || t.trim().isEmpty() ? "none" : t.trim().toLowerCase();
+	}
+
+	/** 当前配置的目标打印机名 */
+	public static String getCurrentName() {
+		return com.mes.print.config.PrinterConfig.get("printer.name", "");
+	}
+
+        /**
+         * 预热:初始化打印实现 + (HP 模式) 提前加载流转卡模板/字体
+         * 建议在主程序启动时异步调用一次,避免首单打印卡顿
+         */
+        public static void warmUp() {
+                long t0 = System.currentTimeMillis();
+                try {
+                        PrinterFactory.get();
+                } catch (Exception e) {
+                        System.err.println("[Printer] 工厂预热失败: " + e.getMessage());
+                        return;
+                }
+                String type = getCurrentType();
+                if ("hp".equals(type)) {
+                        com.mes.print.template.FlowCardRenderer.warmUp();
+                }
+                System.out.println("[Printer] 预热总耗时 " + (System.currentTimeMillis() - t0) + "ms");
+        }
+}

+ 17 - 0
src/com/mes/print/PrinterException.java

@@ -0,0 +1,17 @@
+package com.mes.print;
+
+/**
+ * 打印统一异常。业务代码可选择性 try-catch,也可让它冒泡。
+ */
+public class PrinterException extends RuntimeException {
+
+	private static final long serialVersionUID = 1L;
+
+	public PrinterException(String msg) {
+		super(msg);
+	}
+
+	public PrinterException(String msg, Throwable cause) {
+		super(msg, cause);
+	}
+}

+ 51 - 0
src/com/mes/print/PrinterFactory.java

@@ -0,0 +1,51 @@
+package com.mes.print;
+
+import com.mes.print.config.PrinterConfig;
+import com.mes.print.impl.HpPrinter;
+import com.mes.print.impl.NonePrinter;
+import com.mes.print.impl.PrinterImpl;
+import com.mes.print.impl.TscPrinter;
+
+/**
+ * 按 config 的 printer.type 选择实现(hp / tsc)
+ * 只创建一次,之后复用
+ */
+class PrinterFactory {
+
+	private static volatile PrinterImpl impl;
+
+	static PrinterImpl get() {
+		PrinterImpl r = impl;
+		if (r != null) return r;
+		synchronized (PrinterFactory.class) {
+			if (impl != null) return impl;
+			String type = PrinterConfig.get("printer.type");
+			if (type == null) throw new PrinterException("未配置 printer.type");
+			type = type.trim().toLowerCase();
+			if ("hp".equals(type)) {
+				HpPrinter hp = new HpPrinter();
+				// 如果配置了打印机 IP,启用 SNMP 监控
+				String printerIp = PrinterConfig.get("printer.ip");
+				if (printerIp != null && !printerIp.trim().isEmpty()) {
+					hp.enableSnmpMonitor(printerIp.trim());
+				}
+				impl = hp;
+			} else if ("tsc".equals(type)) {
+				impl = new TscPrinter();
+			} else if ("none".equals(type) || type.isEmpty()) {
+				impl = new NonePrinter();
+			} else {
+				throw new PrinterException("不支持的打印机类型: " + type + "(支持 hp / tsc / none)");
+			}
+			return impl;
+		}
+	}
+
+	/** 测试用:重置单例,重新按 config 创建 */
+	static synchronized void reset() {
+		if (impl instanceof HpPrinter) {
+			((HpPrinter) impl).close();
+		}
+		impl = null;
+	}
+}

+ 48 - 0
src/com/mes/print/TestMain.java

@@ -0,0 +1,48 @@
+package com.mes.print;
+
+import com.mes.print.model.FlowCardData;
+import com.mes.print.model.LabelData;
+
+/**
+ * 手动测试入口。装机时用这个类验证:
+ *   java -cp mes-print.jar com.mes.print.TestMain list     列出本机打印机
+ *   java -cp mes-print.jar com.mes.print.TestMain label    发一张测试标签
+ *   java -cp mes-print.jar com.mes.print.TestMain card     发一张测试流转卡
+ * 需要 config/print.properties 在工作目录下(label/card 时)
+ */
+public class TestMain {
+
+	public static void main(String[] args) {
+		String action = args.length > 0 ? args[0] : "list";
+
+		if ("list".equals(action)) {
+			System.out.println("本机识别到的打印机:");
+			for (String n : Printer.listAvailablePrinters()) {
+				System.out.println("  - " + n);
+			}
+			return;
+		}
+
+		if ("label".equals(action)) {
+			LabelData d = new LabelData();
+			d.setTitle("TEST LABEL");
+			d.setSn("TEST0001");
+			d.setBarcode("TEST0001");
+			d.setQrcode("TEST0001");
+			Printer.printLabel(d);
+			System.out.println("测试标签已发送");
+			return;
+		}
+
+		if ("card".equals(action)) {
+			FlowCardData d = new FlowCardData();
+			d.setSn("+KB24IS3031T721000201");
+			d.setSteelSn("GY100000LX");
+			Printer.printFlowCard(d);
+			System.out.println("测试流转卡已发送");
+			return;
+		}
+
+		System.out.println("用法: TestMain [list|label|card]");
+	}
+}

+ 50 - 0
src/com/mes/print/TestPrintPreview.java

@@ -0,0 +1,50 @@
+package com.mes.print;
+
+import com.mes.print.model.FlowCardData;
+
+/**
+ * 测试打印预览
+ * 快速测试不需要启动主程序
+ */
+public class TestPrintPreview {
+
+    public static void main(String[] args) {
+        try {
+            // 测试数据 - T09 项目
+            String customerSn = "+KB60IS3031T729002101";
+            String steelSn = "T09260729002";
+
+            System.out.println("======================================");
+            System.out.println("测试打印预览");
+            System.out.println("客户码: " + customerSn);
+            System.out.println("钢印码: " + steelSn);
+            System.out.println("======================================");
+
+            // 创建流转卡数据
+            FlowCardData data = new FlowCardData();
+            data.setSn(customerSn);
+            data.setSteelSn(steelSn);
+
+            System.out.println("推断项目号: " + data.getProdCode());
+
+            // 预览
+            System.out.println("正在生成预览 PDF...");
+            com.mes.print.template.FlowCardRenderer.preview(data);
+
+            System.out.println("预览完成!PDF 已用默认程序打开");
+            System.out.println("======================================");
+
+            // 测试 T68 项目
+            System.out.println("\n测试 T68 项目:");
+            FlowCardData data2 = new FlowCardData();
+            data2.setSn("+KB87IS3031T729002102");
+            data2.setSteelSn("T68260729003");
+            System.out.println("客户码: " + data2.getSn());
+            System.out.println("推断项目号: " + data2.getProdCode());
+
+        } catch (Exception e) {
+            System.err.println("预览失败: " + e.getMessage());
+            e.printStackTrace();
+        }
+    }
+}

+ 55 - 0
src/com/mes/print/TestSnmp.java

@@ -0,0 +1,55 @@
+package com.mes.print;
+
+import com.mes.print.monitor.PrinterMonitor;
+
+/**
+ * SNMP 打印机监控测试
+ * 用法:java -cp "build/classes;lib/*" com.mes.print.TestSnmp <打印机IP>
+ */
+public class TestSnmp {
+	public static void main(String[] args) throws Exception {
+		if (args.length < 1) {
+			System.out.println("用法: TestSnmp <打印机IP>");
+			System.out.println("示例: TestSnmp 192.168.1.100");
+			return;
+		}
+
+		String printerIp = args[0];
+		System.out.println("=== SNMP 打印机监控测试 ===");
+		System.out.println("打印机 IP: " + printerIp);
+		System.out.println();
+
+		PrinterMonitor monitor = new PrinterMonitor(printerIp);
+		monitor.init();
+
+		// 测试连接
+		System.out.println("1. 测试连接...");
+		boolean connected = monitor.testConnection();
+		if (!connected) {
+			System.err.println("连接失败,请检查:");
+			System.err.println("  - 打印机 IP 是否正确");
+			System.err.println("  - 打印机是否开启 SNMP(默认端口 161)");
+			System.err.println("  - 防火墙是否放行 UDP 161 端口");
+			monitor.close();
+			return;
+		}
+
+		// 查询当前状态
+		System.out.println("\n2. 查询打印机状态...");
+		Long pageCount = monitor.getPageCount();
+		Integer status = monitor.getPrinterStatus();
+		System.out.println("  总打印页数: " + pageCount);
+		System.out.println("  打印机状态: " + status + " (3=空闲, 4=打印中, 5=预热)");
+
+		// 监控打印完成
+		System.out.println("\n3. 开始监控打印完成(请手动发送打印任务)...");
+		System.out.println("  按 Ctrl+C 退出监控");
+		monitor.waitForPrintComplete(1, () -> {
+			System.out.println("\n✓ 检测到打印完成!");
+			System.exit(0);
+		}, 300); // 5 分钟超时
+
+		// 保持程序运行
+		Thread.sleep(Long.MAX_VALUE);
+	}
+}

+ 103 - 0
src/com/mes/print/config/PrinterConfig.java

@@ -0,0 +1,103 @@
+package com.mes.print.config;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.util.Properties;
+
+import com.mes.print.PrinterException;
+
+/**
+ * 配置加载器。查找顺序:
+ * 1. 系统属性 -Dprint.config=xxx 指定的路径
+ * 2. 当前工作目录 ./config/print.properties
+ * 3. classpath 上的 print.properties(打进 jar 里的默认值)
+ */
+public class PrinterConfig {
+
+	private static final String CONFIG_FILE = "config/print.properties";
+	private static Properties props;
+
+	public static synchronized Properties load() {
+		if (props != null) return props;
+		Properties p = new Properties();
+		try (InputStream in = openStream()) {
+			p.load(in);
+		} catch (Exception e) {
+			throw new PrinterException("加载打印配置失败: " + e.getMessage(), e);
+		}
+		props = p;
+		return props;
+	}
+
+	private static InputStream openStream() throws Exception {
+		// 优先 -D 指定
+		String sysPath = System.getProperty("print.config");
+		if (sysPath != null && new File(sysPath).exists()) {
+			return new FileInputStream(sysPath);
+		}
+		// 其次工作目录
+		File f = new File(CONFIG_FILE);
+		System.out.println("[PrinterConfig] 当前工作目录: " + new File(".").getAbsolutePath());
+		System.out.println("[PrinterConfig] 查找配置文件: " + f.getAbsolutePath() + " (exists=" + f.exists() + ")");
+		if (f.exists()) return new FileInputStream(f);
+		// 最后 classpath
+		InputStream in = PrinterConfig.class.getClassLoader().getResourceAsStream("print.properties");
+		if (in != null) {
+			System.out.println("[PrinterConfig] 从 classpath 加载配置");
+			return in;
+		}
+		throw new PrinterException("找不到配置文件: " + CONFIG_FILE + ",工作目录=" + new File(".").getAbsolutePath());
+	}
+
+	public static String get(String key) {
+		return load().getProperty(key);
+	}
+
+	public static String get(String key, String defaultValue) {
+		String v = load().getProperty(key);
+		return v == null ? defaultValue : v;
+	}
+
+	public static int getInt(String key, int defaultValue) {
+		String v = get(key);
+		if (v == null) return defaultValue;
+		try { return Integer.parseInt(v.trim()); } catch (Exception e) { return defaultValue; }
+	}
+
+	/** 测试用:重置缓存,重新加载 */
+	public static synchronized void reset() {
+		props = null;
+	}
+
+	/**
+	 * 保存配置到 ./config/print.properties。传入 null 表示不修改该项。
+	 * 保存后自动 reset(),下次 get 会重新加载。
+	 */
+	public static synchronized void save(String type, String name) {
+		File f = new File(CONFIG_FILE);
+		Properties p = new Properties();
+		// 保留已有其他键
+		if (f.exists()) {
+			try (InputStream in = new FileInputStream(f)) {
+				p.load(in);
+			} catch (Exception ignored) {
+			}
+		}
+		if (type != null) p.setProperty("printer.type", type);
+		if (name != null) p.setProperty("printer.name", name);
+
+		File parent = f.getAbsoluteFile().getParentFile();
+		if (parent != null && !parent.exists()) parent.mkdirs();
+
+		try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(f), "UTF-8"))) {
+			p.store(bw, "printer settings");
+		} catch (Exception e) {
+			throw new PrinterException("保存打印配置失败: " + e.getMessage(), e);
+		}
+		reset();
+	}
+}

+ 114 - 0
src/com/mes/print/impl/HpPrinter.java

@@ -0,0 +1,114 @@
+package com.mes.print.impl;
+
+import java.awt.print.PageFormat;
+import java.awt.print.Paper;
+import java.awt.print.Printable;
+import java.awt.print.PrinterJob;
+
+import javax.print.PrintService;
+import javax.print.attribute.HashPrintRequestAttributeSet;
+import javax.print.attribute.PrintRequestAttributeSet;
+import javax.print.attribute.standard.Copies;
+import javax.print.attribute.standard.MediaSizeName;
+
+import com.mes.print.PrinterException;
+import com.mes.print.config.PrinterConfig;
+import com.mes.print.model.FlowCardData;
+import com.mes.print.model.LabelData;
+import com.mes.print.template.FlowCardRenderer;
+import com.mes.print.util.PrinterUtil;
+import com.mes.print.monitor.PrinterMonitor;
+
+/**
+ * HP 3004dn 激光打印机
+ * 纸张:读 config 的 printer.paper (A4/A5),默认 A5(模板就是 A5 设计)
+ */
+public class HpPrinter implements PrinterImpl {
+
+    private static final double MM_TO_PT = 2.834645669d;
+
+    private PrinterMonitor monitor;
+    private Runnable onPrintCompleteCallback;
+
+    public void setOnPrintComplete(Runnable callback) {
+        this.onPrintCompleteCallback = callback;
+    }
+
+    public void enableSnmpMonitor(String printerIp) {
+        if (printerIp == null || printerIp.trim().isEmpty()) return;
+        try {
+            monitor = new PrinterMonitor(printerIp.trim());
+            monitor.init();
+            System.out.println("[HpPrinter] SNMP monitor enabled: " + printerIp);
+        } catch (Exception e) {
+            System.err.println("[HpPrinter] SNMP monitor init failed: " + e.getMessage());
+            monitor = null;
+        }
+    }
+
+    @Override
+    public void printFlowCard(FlowCardData data) {
+        if (data == null) throw new PrinterException("流转卡数据为空");
+
+        String name = PrinterConfig.get("printer.name");
+        PrintService svc = PrinterUtil.find(name);
+
+        try {
+            PrinterJob job = PrinterJob.getPrinterJob();
+            job.setPrintService(svc);
+
+            Printable page = FlowCardRenderer.render(data);
+
+            String paperCfg = PrinterConfig.get("printer.paper", "A5").trim().toUpperCase();
+            double pageW, pageH;
+            MediaSizeName media;
+            if ("A4".equals(paperCfg)) {
+                pageW = 210 * MM_TO_PT;
+                pageH = 297 * MM_TO_PT;
+                media = MediaSizeName.ISO_A4;
+            } else {
+                pageW = 148 * MM_TO_PT;
+                pageH = 210 * MM_TO_PT;
+                media = MediaSizeName.ISO_A5;
+            }
+            System.out.println("[HpPrinter] paper=" + paperCfg + " (" + Math.round(pageW) + "x" + Math.round(pageH) + " pt)");
+
+            Paper paper = new Paper();
+            paper.setSize(pageW, pageH);
+            double margin = 15d;
+            paper.setImageableArea(margin, margin, pageW - 2 * margin, pageH - 2 * margin);
+
+            PageFormat pf = new PageFormat();
+            pf.setPaper(paper);
+            pf = job.validatePage(pf);
+
+            job.setPrintable(page, pf);
+
+            PrintRequestAttributeSet attrs = new HashPrintRequestAttributeSet();
+            attrs.add(media);
+            attrs.add(new Copies(1));
+
+            job.print(attrs);
+
+            if (monitor != null && onPrintCompleteCallback != null) {
+                monitor.waitForPrintComplete(1, onPrintCompleteCallback, 60);
+            } else if (onPrintCompleteCallback != null) {
+                onPrintCompleteCallback.run();
+            }
+
+        } catch (PrinterException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new PrinterException("流转卡打印失败: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public void printLabel(LabelData data) {
+        throw new PrinterException("HP 打印机不支持标签打印");
+    }
+
+    public void close() {
+        if (monitor != null) monitor.close();
+    }
+}

+ 21 - 0
src/com/mes/print/impl/NonePrinter.java

@@ -0,0 +1,21 @@
+package com.mes.print.impl;
+
+import com.mes.print.model.FlowCardData;
+import com.mes.print.model.LabelData;
+
+/**
+ * 空实现:当 printer.type=none 时使用。所有打印调用直接静默返回,
+ * 便于业务代码无脑调用 Printer.printXxx(),本机没配打印机时自动跳过。
+ */
+public class NonePrinter implements PrinterImpl {
+
+	@Override
+	public void printFlowCard(FlowCardData data) {
+		// no-op
+	}
+
+	@Override
+	public void printLabel(LabelData data) {
+		// no-op
+	}
+}

+ 14 - 0
src/com/mes/print/impl/PrinterImpl.java

@@ -0,0 +1,14 @@
+package com.mes.print.impl;
+
+import com.mes.print.model.FlowCardData;
+import com.mes.print.model.LabelData;
+
+/**
+ * 打印实现接口(内部使用,业务代码请用 com.mes.print.Printer 门面类)
+ */
+public interface PrinterImpl {
+
+	void printFlowCard(FlowCardData data);
+
+	void printLabel(LabelData data);
+}

+ 45 - 0
src/com/mes/print/impl/TscPrinter.java

@@ -0,0 +1,45 @@
+package com.mes.print.impl;
+
+import javax.print.Doc;
+import javax.print.DocFlavor;
+import javax.print.DocPrintJob;
+import javax.print.PrintService;
+import javax.print.SimpleDoc;
+
+import com.mes.print.PrinterException;
+import com.mes.print.config.PrinterConfig;
+import com.mes.print.model.FlowCardData;
+import com.mes.print.model.LabelData;
+import com.mes.print.template.LabelTspl;
+import com.mes.print.util.PrinterUtil;
+
+/**
+ * TSC TTP-244 Pro 及同类热敏标签机:走 TSPL 原始字节流
+ * 需要在 Windows 装 TSC 官方驱动,Java 通过驱动 pass-through 到打印机
+ */
+public class TscPrinter implements PrinterImpl {
+
+	@Override
+	public void printFlowCard(FlowCardData data) {
+		throw new PrinterException("TSC 打印机不支持流转卡打印,请配置 HP 打印机");
+	}
+
+	@Override
+	public void printLabel(LabelData data) {
+		if (data == null) throw new PrinterException("标签数据为空");
+
+		String name = PrinterConfig.get("printer.name");
+		PrintService svc = PrinterUtil.find(name);
+
+		byte[] tspl = LabelTspl.build(data);
+		try {
+			DocPrintJob job = svc.createPrintJob();
+			Doc doc = new SimpleDoc(tspl, DocFlavor.BYTE_ARRAY.AUTOSENSE, null);
+			job.print(doc, null);
+		} catch (PrinterException e) {
+			throw e;
+		} catch (Exception e) {
+			throw new PrinterException("标签打印失败: " + e.getMessage(), e);
+		}
+	}
+}

+ 36 - 0
src/com/mes/print/model/FlowCardData.java

@@ -0,0 +1,36 @@
+package com.mes.print.model;
+
+/**
+ * 流转卡数据(A4 打印)
+ * PDF 模板打印使用 sn(客户码)、steelSn(钢印码)和自动推断的项目代码
+ */
+public class FlowCardData {
+
+	private String sn;        // 序列号(客户码,如 +KB60IS3031T611001201)
+	private String steelSn;   // 钢印码(如 GY100000LX)
+
+	public String getSn() { return sn; }
+	public void setSn(String sn) { this.sn = sn; }
+
+	public String getSteelSn() { return steelSn; }
+	public void setSteelSn(String steelSn) { this.steelSn = steelSn; }
+
+	/**
+	 * 根据客户码前缀自动推断项目代码
+	 * +KB60 → T09
+	 * +KB87 → T68
+	 * @return 项目代码,无法识别返回空字符串
+	 */
+	public String getProdCode() {
+		if (sn == null || sn.length() < 5) {
+			return "";
+		}
+		String prefix = sn.substring(0, 5); // +KB60 或 +KB87
+		if ("+KB60".equals(prefix)) {
+			return "T09";
+		} else if ("+KB87".equals(prefix)) {
+			return "T68";
+		}
+		return "";
+	}
+}

+ 28 - 0
src/com/mes/print/model/LabelData.java

@@ -0,0 +1,28 @@
+package com.mes.print.model;
+
+/**
+ * 标签数据(TSC 热敏打印)
+ */
+public class LabelData {
+
+	private String title;    // 标题(可选)
+	private String sn;       // 序列号
+	private String barcode;  // 一维条码内容
+	private String qrcode;   // 二维码内容(可选)
+	private int copies = 1;  // 打印份数
+
+	public String getTitle() { return title; }
+	public void setTitle(String title) { this.title = title; }
+
+	public String getSn() { return sn; }
+	public void setSn(String sn) { this.sn = sn; }
+
+	public String getBarcode() { return barcode; }
+	public void setBarcode(String barcode) { this.barcode = barcode; }
+
+	public String getQrcode() { return qrcode; }
+	public void setQrcode(String qrcode) { this.qrcode = qrcode; }
+
+	public int getCopies() { return copies; }
+	public void setCopies(int copies) { this.copies = copies; }
+}

+ 195 - 0
src/com/mes/print/monitor/PrinterMonitor.java

@@ -0,0 +1,195 @@
+package com.mes.print.monitor;
+
+import org.snmp4j.CommunityTarget;
+import org.snmp4j.PDU;
+import org.snmp4j.Snmp;
+import org.snmp4j.event.ResponseEvent;
+import org.snmp4j.mp.SnmpConstants;
+import org.snmp4j.smi.Address;
+import org.snmp4j.smi.GenericAddress;
+import org.snmp4j.smi.OID;
+import org.snmp4j.smi.OctetString;
+import org.snmp4j.smi.VariableBinding;
+import org.snmp4j.transport.DefaultUdpTransportMapping;
+
+import java.io.IOException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 打印机 SNMP 监控器
+ * 通过 SNMP 协议监控 HP 打印机的物理打印完成状态
+ */
+public class PrinterMonitor {
+
+	// SNMP OID 定义
+	private static final String OID_PRINTER_LIFE_COUNT = "1.3.6.1.2.1.43.10.2.1.4.1.1"; // 总打印页数
+	private static final String OID_PRINTER_STATUS = "1.3.6.1.2.1.25.3.5.1.1.1"; // 打印机状态
+
+	private final String printerIp;
+	private final String community; // SNMP 团体名(默认 public)
+	private final int port;
+
+	private Snmp snmp;
+	private CommunityTarget target;
+	private ScheduledExecutorService scheduler;
+
+	public PrinterMonitor(String printerIp) {
+		this(printerIp, "public", 161);
+	}
+
+	public PrinterMonitor(String printerIp, String community, int port) {
+		this.printerIp = printerIp;
+		this.community = community;
+		this.port = port;
+	}
+
+	/**
+	 * 初始化 SNMP 连接
+	 */
+	public void init() throws IOException {
+		DefaultUdpTransportMapping transport = new DefaultUdpTransportMapping();
+		snmp = new Snmp(transport);
+		transport.listen();
+
+		Address targetAddress = GenericAddress.parse("udp:" + printerIp + "/" + port);
+		target = new CommunityTarget();
+		target.setCommunity(new OctetString(community));
+		target.setAddress(targetAddress);
+		target.setRetries(2);
+		target.setTimeout(1500);
+		target.setVersion(SnmpConstants.version2c);
+
+		scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+			Thread t = new Thread(r, "PrinterMonitor-" + printerIp);
+			t.setDaemon(true);
+			return t;
+		});
+
+		System.out.println("[PrinterMonitor] 已连接到打印机:" + printerIp);
+	}
+
+	/**
+	 * 关闭监控
+	 */
+	public void close() {
+		if (scheduler != null) {
+			scheduler.shutdownNow();
+		}
+		if (snmp != null) {
+			try {
+				snmp.close();
+			} catch (IOException e) {
+				e.printStackTrace();
+			}
+		}
+	}
+
+	/**
+	 * 查询打印机总页数计数器
+	 */
+	public Long getPageCount() {
+		try {
+			PDU pdu = new PDU();
+			pdu.add(new VariableBinding(new OID(OID_PRINTER_LIFE_COUNT)));
+			pdu.setType(PDU.GET);
+
+			ResponseEvent event = snmp.send(pdu, target);
+			if (event != null && event.getResponse() != null) {
+				PDU response = event.getResponse();
+				if (response.getErrorStatus() == PDU.noError) {
+					VariableBinding vb = response.get(0);
+					return vb.getVariable().toLong();
+				}
+			}
+		} catch (Exception e) {
+			System.err.println("[PrinterMonitor] 查询页数失败: " + e.getMessage());
+		}
+		return null;
+	}
+
+	/**
+	 * 查询打印机状态
+	 * @return 1=其他, 3=空闲, 4=打印中, 5=预热
+	 */
+	public Integer getPrinterStatus() {
+		try {
+			PDU pdu = new PDU();
+			pdu.add(new VariableBinding(new OID(OID_PRINTER_STATUS)));
+			pdu.setType(PDU.GET);
+
+			ResponseEvent event = snmp.send(pdu, target);
+			if (event != null && event.getResponse() != null) {
+				PDU response = event.getResponse();
+				if (response.getErrorStatus() == PDU.noError) {
+					VariableBinding vb = response.get(0);
+					return vb.getVariable().toInt();
+				}
+			}
+		} catch (Exception e) {
+			System.err.println("[PrinterMonitor] 查询状态失败: " + e.getMessage());
+		}
+		return null;
+	}
+
+	/**
+	 * 监控打印完成(通过页数计数器变化检测)
+	 *
+	 * @param expectedPages 预期增加的页数(通常是 1)
+	 * @param callback 完成回调
+	 * @param timeoutSeconds 超时时间(秒)
+	 */
+	public void waitForPrintComplete(int expectedPages, Runnable callback, int timeoutSeconds) {
+		Long baseCount = getPageCount();
+		if (baseCount == null) {
+			System.err.println("[PrinterMonitor] 无法获取初始页数,跳过监控");
+			callback.run();
+			return;
+		}
+
+		System.out.println("[PrinterMonitor] 开始监控,当前页数=" + baseCount + ",预期增加=" + expectedPages);
+
+		final long startTime = System.currentTimeMillis();
+		final long targetCount = baseCount + expectedPages;
+
+		scheduler.scheduleAtFixedRate(() -> {
+			try {
+				// 检查超时
+				if (System.currentTimeMillis() - startTime > timeoutSeconds * 1000L) {
+					System.err.println("[PrinterMonitor] 监控超时(" + timeoutSeconds + "秒),停止监控");
+					scheduler.shutdown();
+					callback.run();
+					return;
+				}
+
+				// 查询当前页数
+				Long currentCount = getPageCount();
+				if (currentCount == null) {
+					return; // 查询失败,下次重试
+				}
+
+				System.out.println("[PrinterMonitor] 当前页数=" + currentCount + ",目标=" + targetCount);
+
+				// 页数达到预期,打印完成
+				if (currentCount >= targetCount) {
+					System.out.println("[PrinterMonitor] 打印完成检测到!页数从 " + baseCount + " → " + currentCount);
+					scheduler.shutdown();
+					callback.run();
+				}
+			} catch (Exception e) {
+				System.err.println("[PrinterMonitor] 监控异常: " + e.getMessage());
+			}
+		}, 0, 2, TimeUnit.SECONDS); // 每 2 秒查询一次
+	}
+
+	/**
+	 * 测试 SNMP 连接
+	 */
+	public boolean testConnection() {
+		Long count = getPageCount();
+		Integer status = getPrinterStatus();
+		System.out.println("[PrinterMonitor] 测试连接 - 页数=" + count + ", 状态=" + status);
+		return count != null || status != null;
+	}
+}

+ 324 - 0
src/com/mes/print/template/FlowCardRenderer.java

@@ -0,0 +1,324 @@
+package com.mes.print.template;
+
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.GraphicsEnvironment;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.awt.print.PageFormat;
+import java.awt.print.Printable;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.rendering.ImageType;
+import org.apache.pdfbox.rendering.PDFRenderer;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import com.mes.print.model.FlowCardData;
+
+/**
+ * 流转卡渲染器(支持 PDF 模板 + 二维码)
+ * 复用 OP40 的 LaserCardPrintUtil 渲染逻辑
+ */
+public class FlowCardRenderer {
+
+	// PDF 模板尺寸(A5 纵向 148x210mm)
+	private static final float PDF_PAGE_W_PT = 419.5f;
+	private static final float PDF_PAGE_H_PT = 595.25f;
+
+	// 三个动态元素在 PDF 坐标系里的位置(pt,左上原点)
+	private static final float QR_X = 194.5f;
+	private static final float QR_Y = 65.4f - 16.0f;  // 往上移 2 个字体高度(2 * 8pt = 16pt)
+	private static final float QR_W = 35.7f;
+	private static final float QR_H = 34.6f;
+
+	// 文字 baseline(Graphics2D.drawString 的 y 参数)
+	private static final float STEEL_TEXT_X = 291.5f;
+	private static final float STEEL_TEXT_Y = 85.5f - 16.0f;  // 往上移 2 个字体高度
+	private static final float SN_TEXT_X = 291.5f;
+	private static final float SN_TEXT_Y = 96.4f - 16.0f;     // 往上移 2 个字体高度
+	private static final float PROJ_TEXT_X = 291.5f;
+	private static final float PROJ_TEXT_Y = 75.0f - 16.0f;    // 项目号位置(在钢印码上方)
+
+	private static final int TEXT_FONT_SIZE = 8;
+
+	// 中文字体候选,按顺序找到系统里第一个可用的
+	private static final String[] FONT_CANDIDATES = {"SimSun", "宋体", "Microsoft YaHei", "微软雅黑", Font.SANS_SERIF};
+
+	// 打印用的底图 DPI;越高越清晰但内存/耗时越大,300 是常规打印标准
+	private static final float PRINT_TEMPLATE_DPI = 300f;
+
+	// 模板 PDF 缓存
+	private static volatile PDDocument templateDoc;
+	private static volatile PDFRenderer templateRenderer;
+	private static volatile BufferedImage templateImage;
+	private static final Object LOCK = new Object();
+
+	/**
+	 * 清除模板缓存(用于模板更新后强制重新加载)
+	 */
+	public static void clearCache() {
+		synchronized (LOCK) {
+			try {
+				if (templateDoc != null) {
+					templateDoc.close();
+				}
+			} catch (Exception e) {
+				// 忽略
+			}
+			templateDoc = null;
+			templateRenderer = null;
+			templateImage = null;
+			System.out.println("[流转卡] 已清除模板缓存");
+		}
+	}
+
+	public static Printable render(final FlowCardData data) {
+		try {
+			BufferedImage bgImage = getTemplateImage();
+			BufferedImage qr = generateQrCode(data.getSn(), 600);
+
+			return new Printable() {
+				@Override
+				public int print(Graphics g, PageFormat pf, int idx) {
+					if (idx > 0) return NO_SUCH_PAGE;
+					Graphics2D g2 = (Graphics2D) g;
+
+					g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+					g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+					g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
+
+					// 把 PDF 页面尺寸(pt)等比缩放到打印区域,居中
+					double sx = pf.getImageableWidth() / PDF_PAGE_W_PT;
+					double sy = pf.getImageableHeight() / PDF_PAGE_H_PT;
+					double scale = Math.min(sx, sy);
+					double tx = pf.getImageableX() + (pf.getImageableWidth() - PDF_PAGE_W_PT * scale) / 2.0;
+					double ty = pf.getImageableY() + (pf.getImageableHeight() - PDF_PAGE_H_PT * scale) / 2.0;
+
+					g2.translate(tx, ty);
+					g2.scale(scale, scale);
+
+					// 底图 BufferedImage 是 A5 @ 300dpi ≈ 1747x2480 像素,drawImage 到 pt 坐标 419.5x595.25
+					g2.drawImage(bgImage, 0, 0, (int) Math.ceil(PDF_PAGE_W_PT), (int) Math.ceil(PDF_PAGE_H_PT), null);
+
+					drawDynamic(g2, qr, data);
+					return PAGE_EXISTS;
+				}
+			};
+		} catch (Exception e) {
+			throw new RuntimeException("流转卡渲染失败: " + e.getMessage(), e);
+		}
+	}
+
+	private static void drawDynamic(Graphics2D g2, BufferedImage qr, FlowCardData d) {
+		// 白底盖一下原模板上的二维码占位图
+		g2.setColor(Color.WHITE);
+		g2.fill(new java.awt.geom.Rectangle2D.Float(QR_X, QR_Y, QR_W, QR_H));
+		if (qr != null) {
+			g2.drawImage(qr, (int) QR_X, (int) QR_Y, (int) QR_W, (int) QR_H, null);
+		}
+
+		g2.setColor(Color.BLACK);
+		g2.setFont(pickChineseFont(TEXT_FONT_SIZE));
+
+		// 绘制项目号(根据客户码前缀自动推断)
+		String projCode = d.getProdCode();
+		if (projCode != null && !projCode.isEmpty()) {
+			g2.drawString(projCode, PROJ_TEXT_X, PROJ_TEXT_Y);
+		}
+
+		// 绘制钢印码
+		String steelSn = d.getSteelSn();
+		if (steelSn != null) {
+			g2.drawString(steelSn, STEEL_TEXT_X, STEEL_TEXT_Y);
+		}
+
+		// 绘制客户码
+		String customerSn = d.getSn();
+		if (customerSn != null) {
+			g2.drawString(customerSn, SN_TEXT_X, SN_TEXT_Y);
+		}
+	}
+
+	public static BufferedImage getTemplateImage() throws IOException {
+		if (templateImage != null) return templateImage;
+		synchronized (LOCK) {
+			if (templateImage == null) {
+				PDFRenderer r = getRenderer();
+				templateImage = r.renderImageWithDPI(0, PRINT_TEMPLATE_DPI, ImageType.RGB);
+				System.out.println("[流转卡] 底图已预渲染 " + templateImage.getWidth() + "x" + templateImage.getHeight()
+						+ " @" + PRINT_TEMPLATE_DPI + "dpi");
+			}
+		}
+		return templateImage;
+	}
+
+	private static PDFRenderer getRenderer() throws IOException {
+		if (templateRenderer != null) return templateRenderer;
+		synchronized (LOCK) {
+			if (templateRenderer == null) {
+				String path = "config/liuzhuanka_template.pdf";
+				File pdfFile = extractToTemp(path);
+				PDDocument doc = PDDocument.load(pdfFile);
+				templateDoc = doc;
+				templateRenderer = new PDFRenderer(doc);
+				System.out.println("[流转卡] 已加载模板:" + pdfFile.getAbsolutePath());
+			}
+		}
+		return templateRenderer;
+	}
+
+	private static File extractToTemp(String cpPath) throws IOException {
+		File direct = new File(cpPath);
+		if (direct.isFile()) {
+			return direct;
+		}
+		try (InputStream is = ClassLoader.getSystemResourceAsStream(cpPath)) {
+			if (is == null) {
+				throw new IOException("找不到流转卡模板:" + cpPath);
+			}
+			Path tmp = Files.createTempFile("liuzhuanka_template_", ".pdf");
+			tmp.toFile().deleteOnExit();
+			Files.copy(is, tmp, StandardCopyOption.REPLACE_EXISTING);
+			return tmp.toFile();
+		}
+	}
+
+	private static Font pickChineseFont(int size) {
+		String[] available = GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames();
+		for (String name : FONT_CANDIDATES) {
+			for (String a : available) {
+				if (a.equalsIgnoreCase(name)) {
+					return new Font(name, Font.BOLD, size);
+				}
+			}
+		}
+		return new Font(Font.SANS_SERIF, Font.BOLD, size);
+	}
+
+	public static BufferedImage generateQrCode(String content, int pixelSize) {
+		if (content == null || content.isEmpty()) return null;
+		try {
+			Map<EncodeHintType, Object> hints = new HashMap<>();
+			hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
+			hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
+			hints.put(EncodeHintType.MARGIN, 0);
+
+			BitMatrix bm = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, pixelSize, pixelSize, hints);
+			BufferedImage img = new BufferedImage(pixelSize, pixelSize, BufferedImage.TYPE_INT_RGB);
+			for (int x = 0; x < pixelSize; x++) {
+				for (int y = 0; y < pixelSize; y++) {
+					img.setRGB(x, y, bm.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
+				}
+			}
+			return img;
+		} catch (WriterException e) {
+			e.printStackTrace();
+			return null;
+		}
+	}
+
+        /**
+         * 预热:提前加载 PDF 模板 + 渲染底图 + 探测中文字体
+         * 首次调用耗时约 3-5s,后续 render() 只要几十毫秒
+         */
+        public static void warmUp() {
+                long t0 = System.currentTimeMillis();
+                try {
+                        getTemplateImage();
+                        pickChineseFont(TEXT_FONT_SIZE);
+                        System.out.println("[流转卡] 预热完成 " + (System.currentTimeMillis() - t0) + "ms");
+                } catch (Exception e) {
+                        System.err.println("[流转卡] 预热失败: " + e.getMessage());
+                }
+        }
+
+        /**
+         * 预览流转卡:生成临时 PDF 并用系统默认程序打开
+         * @param data 流转卡数据
+         */
+        public static void preview(FlowCardData data) {
+                try {
+                        // 清除缓存,强制重新加载模板
+                        clearCache();
+
+                        // 1. 准备渲染数据
+                        BufferedImage bgImage = getTemplateImage();
+                        BufferedImage qr = generateQrCode(data.getSn(), 600);
+
+                        // 2. 创建预览图片(和打印逻辑一致,使用 Graphics2D)
+                        int previewW = (int) Math.ceil(PDF_PAGE_W_PT * 2); // 2倍分辨率用于预览
+                        int previewH = (int) Math.ceil(PDF_PAGE_H_PT * 2);
+                        BufferedImage previewImage = new BufferedImage(previewW, previewH, BufferedImage.TYPE_INT_RGB);
+                        Graphics2D g2 = previewImage.createGraphics();
+
+                        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+                        g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+                        g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
+
+                        // 白色背景
+                        g2.setColor(Color.WHITE);
+                        g2.fillRect(0, 0, previewW, previewH);
+
+                        // 缩放到预览尺寸
+                        double scale = 2.0;
+                        g2.scale(scale, scale);
+
+                        // 绘制底图
+                        g2.drawImage(bgImage, 0, 0, (int) PDF_PAGE_W_PT, (int) PDF_PAGE_H_PT, null);
+
+                        // 绘制动态内容(复用打印逻辑)
+                        drawDynamic(g2, qr, data);
+                        g2.dispose();
+
+                        // 3. 创建 PDF
+                        Path tempPdf = Files.createTempFile("flowcard_preview_", ".pdf");
+                        tempPdf.toFile().deleteOnExit();
+
+                        PDDocument previewDoc = new PDDocument();
+                        org.apache.pdfbox.pdmodel.PDPage page = new org.apache.pdfbox.pdmodel.PDPage(
+                                new org.apache.pdfbox.pdmodel.common.PDRectangle(PDF_PAGE_W_PT, PDF_PAGE_H_PT));
+                        previewDoc.addPage(page);
+
+                        // 4. 将预览图片写入 PDF
+                        org.apache.pdfbox.pdmodel.PDPageContentStream cs =
+                                new org.apache.pdfbox.pdmodel.PDPageContentStream(previewDoc, page);
+                        org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject pdImage =
+                                org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory.createFromImage(previewDoc, previewImage);
+                        cs.drawImage(pdImage, 0, 0, PDF_PAGE_W_PT, PDF_PAGE_H_PT);
+                        cs.close();
+
+                        // 5. 保存并打开
+                        previewDoc.save(tempPdf.toFile());
+                        previewDoc.close();
+
+                        System.out.println("[流转卡预览] 已生成: " + tempPdf.toAbsolutePath());
+
+                        if (java.awt.Desktop.isDesktopSupported()) {
+                                java.awt.Desktop.getDesktop().open(tempPdf.toFile());
+                        } else {
+                                System.err.println("[流转卡预览] 系统不支持 Desktop.open()");
+                        }
+
+                } catch (Exception e) {
+                        System.err.println("[流转卡预览] 失败: " + e.getMessage());
+                        e.printStackTrace();
+                        throw new RuntimeException("预览失败: " + e.getMessage(), e);
+                }
+        }
+
+}

+ 58 - 0
src/com/mes/print/template/LabelTspl.java

@@ -0,0 +1,58 @@
+package com.mes.print.template;
+
+import java.io.UnsupportedEncodingException;
+
+import com.mes.print.PrinterException;
+import com.mes.print.model.LabelData;
+
+/**
+ * TSPL 指令生成器(TSC TTP-244 Pro)
+ * 默认标签规格 40x30mm,用户可后续按真实规格调整常量或改为读 config
+ * 注意:TSC 244 默认无中文字库,此模板只使用 ASCII 内容
+ */
+public class LabelTspl {
+
+	// TODO: 后续按真实标签规格调整
+	private static final String SIZE       = "40 mm,30 mm";
+	private static final String GAP        = "2 mm,0 mm";
+	private static final int    DIRECTION  = 1;
+
+	public static byte[] build(LabelData d) {
+		StringBuilder sb = new StringBuilder(256);
+		sb.append("SIZE ").append(SIZE).append("\r\n");
+		sb.append("GAP ").append(GAP).append("\r\n");
+		sb.append("DIRECTION ").append(DIRECTION).append("\r\n");
+		sb.append("CLS\r\n");
+
+		if (nonEmpty(d.getTitle())) {
+			sb.append("TEXT 20,20,\"3\",0,1,1,\"").append(esc(d.getTitle())).append("\"\r\n");
+		}
+		if (nonEmpty(d.getSn())) {
+			sb.append("TEXT 20,60,\"2\",0,1,1,\"SN:").append(esc(d.getSn())).append("\"\r\n");
+		}
+		if (nonEmpty(d.getBarcode())) {
+			sb.append("BARCODE 20,100,\"128\",60,1,0,2,2,\"").append(esc(d.getBarcode())).append("\"\r\n");
+		}
+		if (nonEmpty(d.getQrcode())) {
+			sb.append("QRCODE 260,60,H,5,A,0,\"").append(esc(d.getQrcode())).append("\"\r\n");
+		}
+
+		int copies = d.getCopies() <= 0 ? 1 : d.getCopies();
+		sb.append("PRINT 1,").append(copies).append("\r\n");
+
+		try {
+			return sb.toString().getBytes("US-ASCII");
+		} catch (UnsupportedEncodingException e) {
+			throw new PrinterException("生成 TSPL 失败", e);
+		}
+	}
+
+	private static boolean nonEmpty(String s) {
+		return s != null && !s.isEmpty();
+	}
+
+	/** 简单转义 TSPL 引号 */
+	private static String esc(String s) {
+		return s.replace("\"", "'");
+	}
+}

+ 190 - 0
src/com/mes/print/ui/PrinterSettingDialog.java

@@ -0,0 +1,190 @@
+package com.mes.print.ui;
+
+import com.mes.print.Printer;
+import com.mes.print.model.FlowCardData;
+import com.mes.print.model.LabelData;
+
+import javax.swing.*;
+import java.awt.*;
+import java.util.List;
+
+/**
+ * 打印机设置对话框(通用版,跟随 mes-print.jar 一起发布)。
+ * 任何 Swing 客户端都可以直接使用:
+ * <pre>
+ *   new PrinterSettingDialog(parentFrame).setVisible(true);
+ * </pre>
+ * 保存后立即生效,无需重启。
+ */
+public class PrinterSettingDialog extends JDialog {
+
+	private static final Font FONT_LABEL = new Font("微软雅黑", Font.PLAIN, 18);
+	private static final Font FONT_INPUT = new Font("微软雅黑", Font.PLAIN, 16);
+
+	private JRadioButton hpRadio;
+	private JRadioButton tscRadio;
+	private JRadioButton noneRadio;
+	private JComboBox<String> printerCombo;
+
+	public PrinterSettingDialog(Frame owner) {
+		super(owner, "打印机设置", true);
+		buildUI();
+		loadCurrent();
+		reloadPrinters();
+		pack();
+		setLocationRelativeTo(owner);
+	}
+
+	private void buildUI() {
+		setLayout(new BorderLayout(0, 0));
+
+		JPanel center = new JPanel(new GridBagLayout());
+		center.setBorder(BorderFactory.createEmptyBorder(20, 25, 10, 25));
+		GridBagConstraints c = new GridBagConstraints();
+		c.insets = new Insets(10, 8, 10, 8);
+		c.anchor = GridBagConstraints.WEST;
+
+		c.gridx = 0; c.gridy = 0;
+		center.add(newLabel("打印类型:"), c);
+
+		hpRadio   = newRadio("流转卡 (HP)");
+		tscRadio  = newRadio("标签 (TSC)");
+		noneRadio = newRadio("不打印");
+		ButtonGroup g = new ButtonGroup();
+		g.add(hpRadio); g.add(tscRadio); g.add(noneRadio);
+		JPanel typePanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 15, 0));
+		typePanel.add(hpRadio); typePanel.add(tscRadio); typePanel.add(noneRadio);
+		c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0;
+		center.add(typePanel, c);
+
+		c.gridx = 0; c.gridy = 1; c.fill = GridBagConstraints.NONE; c.weightx = 0;
+		center.add(newLabel("目标打印机:"), c);
+
+		printerCombo = new JComboBox<>();
+		printerCombo.setEditable(true);
+		printerCombo.setFont(FONT_INPUT);
+		printerCombo.setPreferredSize(new Dimension(320, 32));
+
+		JButton refreshBtn = newButton("刷新");
+		refreshBtn.addActionListener(e -> reloadPrinters());
+
+		JPanel printerPanel = new JPanel(new BorderLayout(6, 0));
+		printerPanel.add(printerCombo, BorderLayout.CENTER);
+		printerPanel.add(refreshBtn, BorderLayout.EAST);
+		c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0;
+		center.add(printerPanel, c);
+
+		JLabel hint = new JLabel("<html><span style='color:gray'>下拉列出本机所有系统打印机,也可手动输入名字片段(支持模糊匹配)</span></html>");
+		hint.setFont(new Font("微软雅黑", Font.PLAIN, 12));
+		c.gridx = 0; c.gridy = 2; c.gridwidth = 2; c.fill = GridBagConstraints.HORIZONTAL;
+		center.add(hint, c);
+
+		add(center, BorderLayout.CENTER);
+
+		JPanel bottom = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 10));
+		JButton testBtn   = newButton("测试打印");
+		JButton okBtn     = newButton("保存");
+		JButton cancelBtn = newButton("取消");
+		testBtn.addActionListener(e -> doTest());
+		okBtn.addActionListener(e -> doSave());
+		cancelBtn.addActionListener(e -> dispose());
+		bottom.add(testBtn); bottom.add(okBtn); bottom.add(cancelBtn);
+		add(bottom, BorderLayout.SOUTH);
+	}
+
+	private JLabel newLabel(String text) {
+		JLabel l = new JLabel(text);
+		l.setFont(FONT_LABEL);
+		return l;
+	}
+
+	private JRadioButton newRadio(String text) {
+		JRadioButton r = new JRadioButton(text);
+		r.setFont(FONT_LABEL);
+		return r;
+	}
+
+	private JButton newButton(String text) {
+		JButton b = new JButton(text);
+		b.setFont(FONT_INPUT);
+		return b;
+	}
+
+	private void loadCurrent() {
+		String type = Printer.getCurrentType();
+		String name = Printer.getCurrentName();
+		if ("hp".equals(type))       hpRadio.setSelected(true);
+		else if ("tsc".equals(type)) tscRadio.setSelected(true);
+		else                         noneRadio.setSelected(true);
+		if (name != null && !name.isEmpty()) printerCombo.setSelectedItem(name);
+	}
+
+	private void reloadPrinters() {
+		Object cur = printerCombo.getSelectedItem();
+		printerCombo.removeAllItems();
+		List<String> all = Printer.listAvailablePrinters();
+		for (String n : all) printerCombo.addItem(n);
+		if (cur != null && !cur.toString().isEmpty()) {
+			printerCombo.setSelectedItem(cur);
+		}
+	}
+
+	private String currentType() {
+		if (hpRadio.isSelected())  return "hp";
+		if (tscRadio.isSelected()) return "tsc";
+		return "none";
+	}
+
+	private String currentName() {
+		Object v = printerCombo.getSelectedItem();
+		return v == null ? "" : v.toString().trim();
+	}
+
+	private void doTest() {
+		String type = currentType();
+		String name = currentName();
+		if ("none".equals(type)) { msg("请先选择打印类型(流转卡/标签)"); return; }
+		if (name.isEmpty())      { msg("请先选择或填写目标打印机"); return; }
+		try {
+			Printer.setPrinter(type, name);
+			if ("hp".equals(type)) sendTestFlowCard();
+			else                   sendTestLabel();
+			msg("测试打印已发送");
+		} catch (Exception ex) {
+			err("测试打印失败:" + ex.getMessage());
+		}
+	}
+
+	private void sendTestFlowCard() {
+		FlowCardData d = new FlowCardData();
+		d.setSn("+TEST0001");
+		d.setSteelSn("TEST-STEEL");
+		Printer.printFlowCard(d);
+	}
+
+	private void sendTestLabel() {
+		LabelData l = new LabelData();
+		l.setTitle("TEST");
+		l.setSn("TEST0001");
+		l.setBarcode("TEST0001");
+		Printer.printLabel(l);
+	}
+
+	private void doSave() {
+		String type = currentType();
+		String name = currentName();
+		if (!"none".equals(type) && name.isEmpty()) {
+			msg("请选择或填写目标打印机"); return;
+		}
+		try {
+			Printer.setPrinter(type, "none".equals(type) ? "" : name);
+			msg("打印机配置已保存");
+			dispose();
+		} catch (Exception ex) {
+			err("保存失败:" + ex.getMessage());
+		}
+	}
+
+	private void msg(String s) { JOptionPane.showMessageDialog(this, s, "提示", JOptionPane.INFORMATION_MESSAGE); }
+	private void err(String s) { JOptionPane.showMessageDialog(this, s, "错误", JOptionPane.ERROR_MESSAGE); }
+}

+ 41 - 0
src/com/mes/print/util/PrinterUtil.java

@@ -0,0 +1,41 @@
+package com.mes.print.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.print.PrintService;
+import javax.print.PrintServiceLookup;
+
+import com.mes.print.PrinterException;
+
+/**
+ * 打印机查找工具
+ */
+public class PrinterUtil {
+
+	/** 按名字精确或模糊匹配(contains),找不到抛异常 */
+	public static PrintService find(String name) {
+		if (name == null || name.trim().isEmpty()) {
+			throw new PrinterException("未配置打印机名 (printer.name)");
+		}
+		PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
+		// 先精确匹配
+		for (PrintService s : services) {
+			if (s.getName().equalsIgnoreCase(name)) return s;
+		}
+		// 再模糊匹配
+		for (PrintService s : services) {
+			if (s.getName().toLowerCase().contains(name.toLowerCase())) return s;
+		}
+		throw new PrinterException("未找到打印机: " + name + ",本机可用: " + listNames());
+	}
+
+	/** 列出本机所有可用打印机名字 */
+	public static List<String> listNames() {
+		List<String> list = new ArrayList<>();
+		for (PrintService s : PrintServiceLookup.lookupPrintServices(null, null)) {
+			list.add(s.getName());
+		}
+		return list;
+	}
+}