wangxichen 2 dni temu
rodzic
commit
ef186b237d

+ 253 - 0
PLC信号监控工具说明.md

@@ -0,0 +1,253 @@
+# OP50 PLC 信号监控测试工具
+
+## 功能说明
+
+独立可运行的 S7-1200 信号监控工具,不依赖 MES 服务器连接,直连 PLC 读写 DB200 点位,用于现场调试和验证。
+
+---
+
+## 快速启动
+
+### 方法1:IDE 运行
+直接运行 `com.mes.test.PlcSignalTest` 的 main 方法
+
+### 方法2:命令行
+```bash
+# 默认 IP: 192.168.1.1
+java -cp <classpath> com.mes.test.PlcSignalTest
+
+# 指定 IP
+java -cp <classpath> com.mes.test.PlcSignalTest 192.168.1.100
+```
+
+---
+
+## 界面布局
+
+### 1. 连接控制区
+- **PLC IP 输入框**:默认 192.168.1.1,可修改
+- **连接/断开按钮**:后台线程连接,不卡 UI
+- **连接状态灯**:绿=已连接,灰=未连接
+- 显示设备型号:S7-1200 / DB200
+
+### 2. 转台位置区(只读)
+| 点位 | 含义 | 显示 |
+|------|------|------|
+| DB200.156.3 | A面到位传感器 | 绿点=到位,灰点=未到位 |
+| DB200.156.4 | B面到位传感器 | 绿点=到位,灰点=未到位 |
+| 推算当前面 | 根据传感器推算 | A / B / -- |
+
+**验证方式**:手动转动转台,观察灯变化和当前面显示
+
+---
+
+### 3. MES允许启动区(可读写)⚠️
+
+| 点位 | 含义 | 操作 |
+|------|------|------|
+| DB200.0.1 | A面MES允许启动 | 置位 true / 复位 false |
+| DB200.38.0 | B面MES允许启动 | 置位 true / 复位 false |
+
+**显示**:
+- 绿点 = true(PLC 启动按钮解锁)
+- 灰点 = false(PLC 启动按钮锁定)
+
+**写操作流程**:
+1. 点击「置位 true」或「复位 false」
+2. 弹出确认框(防止误操作)
+3. 确认后执行写入
+4. 日志区显示写入结果
+
+**现场验证步骤**:
+```
+1. 扫码完成后,正常流程应该看到 A面许可 = true(绿灯)
+2. 此时观察 PLC 面板:A面启动按钮应该亮起/解锁
+3. 手动点「复位 false」→ 启动按钮应该熄灭/锁定
+4. 再点「置位 true」→ 启动按钮重新亮起
+```
+
+---
+
+### 4. 焊接状态区(只读)
+
+| 点位 | 含义 | 显示 |
+|------|------|------|
+| DB200.34 | A面状态(Int16) | 数值 + 文字 + 状态灯 |
+| DB200.72 | B面状态(Int16) | 数值 + 文字 + 状态灯 |
+
+**状态含义**:
+- `0 空闲` — 灰灯
+- `1 运行中` — 黄灯
+- `2 完成` — 绿灯
+
+**应答清零按钮**:
+- 手动写 0 到状态地址,模拟 MES 应答 PLC
+- 点击前弹确认框
+- 正常流程:PLC 写 2(完成)→ MES 读到后应答写 0 → PLC 清除完成标志
+
+**验证步骤**:
+```
+1. PLC 焊接完成 → 状态变为 2(绿灯)
+2. 等待 MES 自动应答(正常情况)
+3. 如果测试手动应答:点「应答清零」→ 状态变回 0
+```
+
+---
+
+### 5. 焊接参数区(只读)
+
+表格显示 R1~R4 四个焊点的实时参数:
+
+| 列 | 点位偏移 | 数据类型 | 说明 |
+|----|----------|----------|------|
+| 焊点 | - | - | R1/R2/R3/R4 |
+| 完成标志 | 76.1 / 96.2 / 116.2 / 136.2 | Bool | ● = true, ○ = false |
+| 序号 | 78 / 98 / 118 / 138 | Int16 | 错误代码 |
+| 电流 | 80 / 100 / 120 / 140 | Float32 | 单位 A |
+| 电压 | 84 / 104 / 124 / 144 | Float32 | 单位 V |
+| 送丝速度 | 88 / 108 / 128 / 148 | Float32 | m/min |
+| 焊接速度 | 92 / 112 / 132 / 152 | Float32 | mm/s |
+
+**刷新频率**:1 秒
+
+**验证步骤**:
+```
+1. PLC 启动焊接 → 状态变 1(运行中)
+2. 观察表格实时更新电流/电压/速度
+3. 焊接完成 → 完成标志变 ●(绿点)
+4. 对比 PLC 触摸屏上的参数是否一致
+```
+
+---
+
+### 6. 日志区
+
+底部滚动文本框,记录:
+- 连接/断开事件
+- 所有写操作及结果
+- 读取异常(不中断轮询,仅记录)
+
+格式:`[HH:mm:ss] 消息内容`
+
+---
+
+## 监控逻辑
+
+### 轮询周期
+- 首次启动:0.5 秒
+- 正常运行:1 秒
+
+### 读取失败处理
+- 不弹窗、不中断轮询
+- 对应显示 `--` 或 `-1`
+- 静默记录到控制台(System.err)
+
+### 写入失败处理
+- 弹窗提示错误信息
+- 日志区记录详细原因
+
+---
+
+## 常见问题
+
+### 1. 连接失败
+**原因**:
+- IP 地址错误
+- PLC 未开机或网络不通
+- 防火墙拦截
+
+**解决**:
+```bash
+# 先 ping 测试
+ping 192.168.1.1
+
+# 检查网络配置
+# S7-1200 默认端口:102(自动使用)
+```
+
+### 2. 完成标志地址疑问
+当前代码中 R1 的完成标志是 `DB200.76.1`(位偏移 .1),而 R2~R4 是 `.2`。
+- 如果现场验证不一致,修改 `PlcSignalPanel.WELD_POINTS` 数组
+- 地址格式:`DB编号.字节偏移.位偏移`
+
+### 3. 参数显示异常
+**检查项**:
+- PLC 侧 DB200 是否允许外部读取(优化块访问属性)
+- 数据类型是否匹配(Bool/Int16/Float32)
+- 字节对齐(Float32 必须从偶数地址开始)
+
+---
+
+## 测试检查清单
+
+### 基础连接
+- [ ] 能成功连接 PLC
+- [ ] 断开后所有显示变灰
+- [ ] 重新连接恢复正常
+
+### 转台位置
+- [ ] 转台在 A 面 → A 灯绿,当前面显示 A
+- [ ] 转台在 B 面 → B 灯绿,当前面显示 B
+- [ ] 转台移动中 → 两灯都灰
+
+### MES许可信号
+- [ ] 置位 A 面 → PLC 启动按钮 A 解锁
+- [ ] 复位 A 面 → PLC 启动按钮 A 锁定
+- [ ] 置位 B 面 → PLC 启动按钮 B 解锁
+- [ ] 复位 B 面 → PLC 启动按钮 B 锁定
+
+### 焊接状态
+- [ ] 启动前 → 0 空闲(灰灯)
+- [ ] 焊接中 → 1 运行中(黄灯)
+- [ ] 焊接完成 → 2 完成(绿灯)
+- [ ] 应答清零 → 状态变 0
+
+### 焊接参数
+- [ ] 焊接中参数实时更新(1秒刷新)
+- [ ] 完成标志正确显示 ●/○
+- [ ] 数值与 PLC 触摸屏一致
+
+---
+
+## 注意事项
+
+⚠️ **写操作影响真实 PLC**
+- 所有写操作都有确认弹窗
+- 误操作可能导致设备异常启动
+- 建议在非生产时段测试
+
+⚠️ **不替代正式 MES 客户端**
+- 仅用于调试和信号验证
+- 不记录数据到 MES 服务器
+- 不保存焊接参数到 SQLite
+
+⚠️ **网络安全**
+- 测试工具直连 PLC,绕过 MES 权限控制
+- 不要在生产环境长期运行
+- 使用完毕断开连接
+
+---
+
+## 文件位置
+
+```
+src/
+├── com/mes/test/
+│   └── PlcSignalTest.java          # 测试入口(main)
+└── com/mes/ui/
+    └── PlcSignalPanel.java         # 监控面板实现
+```
+
+---
+
+## 依赖库
+
+与正式客户端相同:
+- `iot-communication.jar` — S7 PLC 通信库
+- Swing(JDK 自带)
+
+---
+
+## 技术支持
+
+问题反馈:检查日志区输出,记录具体错误信息

BIN
config/liuzhuanka_template.pdf


BIN
lib/mes-print.jar


BIN
lib/snmp4j-3.7.7.jar


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

@@ -0,0 +1,469 @@
+package com.mes.print;
+
+import com.mes.print.model.FlowCardData;
+import com.mes.print.template.FlowCardRenderer;
+
+import javax.imageio.ImageIO;
+import javax.swing.*;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * 流转卡坐标调整工具
+ * 可视化调整二维码和文字位置
+ */
+public class CoordinateAdjustTool extends JFrame {
+
+    // 当前坐标(初始值从 FlowCardRenderer 获取)
+    private float qrX = 194.5f;
+    private float qrY = 65.4f - 4.0f;  // 当前已上移4pt
+    private float qrW = 35.7f;
+    private float qrH = 34.6f;
+
+    private float steelTextX = 291.5f;
+    private float steelTextY = 85.5f - 4.0f;  // 当前已上移4pt
+    private float snTextX = 291.5f;
+    private float snTextY = 96.4f - 4.0f;     // 当前已上移4pt
+
+    private JLabel previewLabel;
+    private FlowCardData testData;
+
+    // 拖拽相关
+    private boolean draggingQr = false;
+    private Point dragStart = null;
+
+    public CoordinateAdjustTool() {
+        setTitle("流转卡坐标调整工具");
+        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);
+        JButton updateBtn = new JButton("更新预览");
+        updateBtn.addActionListener(e -> {
+            testData.setSn(snField.getText());
+            testData.setSteelSn(steelSnField.getText());
+            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);
+
+        JPanel qrXPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+        qrXPanel.add(new JLabel("X:"));
+        qrXPanel.add(qrXField);
+        qrXField.addActionListener(e -> {
+            try {
+                qrX = Float.parseFloat(qrXField.getText());
+                updatePreview();
+            } catch (NumberFormatException ex) {
+                qrXField.setText(String.format("%.1f", qrX));
+            }
+        });
+        controlPanel.add(qrXPanel);
+        controlPanel.add(createSlider("X坐标", qrX, 100f, 300f, v -> {
+            qrX = v;
+            qrXField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+
+        JPanel qrYPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+        qrYPanel.add(new JLabel("Y:"));
+        qrYPanel.add(qrYField);
+        qrYField.addActionListener(e -> {
+            try {
+                qrY = Float.parseFloat(qrYField.getText());
+                updatePreview();
+            } catch (NumberFormatException ex) {
+                qrYField.setText(String.format("%.1f", qrY));
+            }
+        });
+        controlPanel.add(qrYPanel);
+        controlPanel.add(createSlider("Y坐标", qrY, 30f, 150f, v -> {
+            qrY = v;
+            qrYField.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);
+
+        JPanel steelXPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+        steelXPanel.add(new JLabel("X:"));
+        steelXPanel.add(steelXField);
+        steelXField.addActionListener(e -> {
+            try {
+                steelTextX = Float.parseFloat(steelXField.getText());
+                updatePreview();
+            } catch (NumberFormatException ex) {
+                steelXField.setText(String.format("%.1f", steelTextX));
+            }
+        });
+        controlPanel.add(steelXPanel);
+        controlPanel.add(createSlider("X坐标", steelTextX, 200f, 400f, v -> {
+            steelTextX = v;
+            steelXField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+
+        JPanel steelYPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+        steelYPanel.add(new JLabel("Y:"));
+        steelYPanel.add(steelYField);
+        steelYField.addActionListener(e -> {
+            try {
+                steelTextY = Float.parseFloat(steelYField.getText());
+                updatePreview();
+            } catch (NumberFormatException ex) {
+                steelYField.setText(String.format("%.1f", steelTextY));
+            }
+        });
+        controlPanel.add(steelYPanel);
+        controlPanel.add(createSlider("Y坐标", 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);
+
+        JPanel snXPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+        snXPanel.add(new JLabel("X:"));
+        snXPanel.add(snXField);
+        snXField.addActionListener(e -> {
+            try {
+                snTextX = Float.parseFloat(snXField.getText());
+                updatePreview();
+            } catch (NumberFormatException ex) {
+                snXField.setText(String.format("%.1f", snTextX));
+            }
+        });
+        controlPanel.add(snXPanel);
+        controlPanel.add(createSlider("X坐标", snTextX, 200f, 400f, v -> {
+            snTextX = v;
+            snXField.setText(String.format("%.1f", v));
+            updatePreview();
+        }));
+
+        JPanel snYPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+        snYPanel.add(new JLabel("Y:"));
+        snYPanel.add(snYField);
+        snYField.addActionListener(e -> {
+            try {
+                snTextY = Float.parseFloat(snYField.getText());
+                updatePreview();
+            } catch (NumberFormatException ex) {
+                snYField.setText(String.format("%.1f", snTextY));
+            }
+        });
+        controlPanel.add(snYPanel);
+        controlPanel.add(createSlider("Y坐标", 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();
+
+        // 计算图像在 label 中的偏移(居中显示)
+        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;
+
+        // 转换为 PDF 坐标系
+        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;
+
+        // 转换为 PDF 坐标系偏移
+        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 createSlider(String label, 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);
+        });
+
+        JPanel topPanel = new JPanel(new BorderLayout());
+        topPanel.add(nameLabel, BorderLayout.WEST);
+        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 {
+        // 获取实际 PDF 模板背景图
+        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.drawImage(templateBg, 0, 0, null);
+
+        // PDF 坐标转图像坐标(PDF 是 72 DPI,模板图是 300 DPI)
+        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)));
+
+        // 绘制钢印码文字
+        int imgSteelX = (int)(steelTextX * scale);
+        int imgSteelY = (int)(steelTextY * scale);
+        if (testData.getSteelSn() != null) {
+            g2.drawString(testData.getSteelSn(), imgSteelX, imgSteelY);
+        }
+
+        // 绘制客户码文字
+        int imgSnX = (int)(snTextX * scale);
+        int imgSnY = (int)(snTextY * scale);
+        if (testData.getSn() != null) {
+            g2.drawString(testData.getSn(), imgSnX, imgSnY);
+        }
+
+        // 绘制辅助标记(红色十字标记钢印码位置)
+        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);
+
+        // 绘制辅助标记(绿色十字标记客户码位置)
+        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)", steelTextX, steelTextY), 20, 90);
+        g2.drawString(String.format("客户: (%.1f, %.1f)", snTextX, snTextY), 20, 130);
+
+        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 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,
+            steelTextX, steelTextY, snTextX, snTextY
+        );
+
+        JTextArea textArea = new JTextArea(code, 12, 50);
+        textArea.setFont(new Font("Consolas", Font.PLAIN, 12));
+        textArea.setEditable(false);
+        JOptionPane.showMessageDialog(this, new JScrollPane(textArea), "生成的代码", JOptionPane.INFORMATION_MESSAGE);
+    }
+
+    private void previewPdf() {
+        // TODO: 调用实际的预览方法
+        JOptionPane.showMessageDialog(this, "请在 IDE 中运行 TestPrintPreview 查看实际 PDF 效果");
+    }
+
+    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]");
+	}
+}

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

@@ -0,0 +1,40 @@
+package com.mes.print;
+
+import com.mes.print.model.FlowCardData;
+
+/**
+ * 测试打印预览
+ * 快速测试不需要启动主程序
+ */
+public class TestPrintPreview {
+
+    public static void main(String[] args) {
+        try {
+            // 测试数据
+            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("正在生成预览 PDF...");
+            com.mes.print.template.FlowCardRenderer.preview(data);
+
+            System.out.println("预览完成!PDF 已用默认程序打开");
+            System.out.println("======================================");
+
+        } 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;
+	}
+}

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

@@ -0,0 +1,360 @@
+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.1f;
+	private static final float QR_Y = 63.3f;
+	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 = 77.5f;
+	private static final float SN_TEXT_X = 286.7f;
+	private static final float SN_TEXT_Y = 89.4f;
+
+	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);
+
+					// 打印时使用 PDF 坐标(不需要转换)
+					drawDynamicForPrint(g2, qr, data);
+					return PAGE_EXISTS;
+				}
+			};
+		} catch (Exception e) {
+			throw new RuntimeException("流转卡渲染失败: " + e.getMessage(), e);
+		}
+	}
+
+	/**
+	 * 在打印 Graphics2D 上绘制动态内容(坐标系已经是 PDF pt)
+	 */
+	private static void drawDynamicForPrint(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 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);
+		}
+	}
+
+	/**
+	 * 在预览图像上绘制动态内容(需要坐标转换:PDF pt -> 300 DPI 图像坐标)
+	 */
+	private static void drawDynamic(Graphics2D g2, BufferedImage qr, FlowCardData d) {
+		// PDF 坐标转图像坐标(PDF 是 72 DPI,模板图是 300 DPI)
+		double scale = PRINT_TEMPLATE_DPI / 72.0;
+
+		// 白底盖一下原模板上的二维码占位图
+		g2.setColor(Color.WHITE);
+		g2.fill(new java.awt.geom.Rectangle2D.Float(
+			(float)(QR_X * scale),
+			(float)(QR_Y * scale),
+			(float)(QR_W * scale),
+			(float)(QR_H * scale)));
+		if (qr != null) {
+			g2.drawImage(qr,
+				(int)(QR_X * scale),
+				(int)(QR_Y * scale),
+				(int)(QR_W * scale),
+				(int)(QR_H * scale), null);
+		}
+
+		g2.setColor(Color.BLACK);
+		// 字体大小也要缩放
+		g2.setFont(pickChineseFont((int)(TEXT_FONT_SIZE * scale)));
+
+		// 绘制钢印码
+		String steelSn = d.getSteelSn();
+		if (steelSn != null) {
+			g2.drawString(steelSn, (float)(STEEL_TEXT_X * scale), (float)(STEEL_TEXT_Y * scale));
+		}
+
+		// 绘制客户码
+		String customerSn = d.getSn();
+		if (customerSn != null) {
+			g2.drawString(customerSn, (float)(SN_TEXT_X * scale), (float)(SN_TEXT_Y * scale));
+		}
+	}
+
+	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) {
+                PDDocument previewDoc = null;
+                try {
+                        // 清除缓存,强制重新加载模板
+                        clearCache();
+
+                        // 1. 准备渲染数据
+                        BufferedImage bgImage = getTemplateImage();
+                        BufferedImage qr = generateQrCode(data.getSn(), 600);
+
+                        // 2. 创建完整的打印图像(使用打印逻辑)
+                        int imgW = bgImage.getWidth();
+                        int imgH = bgImage.getHeight();
+                        BufferedImage finalImage = new BufferedImage(imgW, imgH, BufferedImage.TYPE_INT_RGB);
+                        Graphics2D g2 = finalImage.createGraphics();
+
+                        // 绘制底图
+                        g2.drawImage(bgImage, 0, 0, null);
+
+                        // 绘制动态内容(二维码和文字)
+                        drawDynamic(g2, qr, data);
+                        g2.dispose();
+
+                        // 3. 创建 PDF 并写入图片
+                        Path tempPdf = Files.createTempFile("flowcard_preview_", ".pdf");
+
+                        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);
+
+                        org.apache.pdfbox.pdmodel.PDPageContentStream contentStream =
+                                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, finalImage);
+
+                        contentStream.drawImage(pdImage, 0, 0, PDF_PAGE_W_PT, PDF_PAGE_H_PT);
+                        contentStream.close();
+
+                        // 4. 保存文档
+                        previewDoc.save(tempPdf.toFile());
+
+                        System.out.println("[流转卡预览] 已生成: " + tempPdf.toAbsolutePath());
+
+                        // 5. 先关闭文档再打开文件
+                        previewDoc.close();
+                        previewDoc = null;
+
+                        // 6. 用系统默认程序打开
+                        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);
+                } finally {
+                        if (previewDoc != null) {
+                                try {
+                                        previewDoc.close();
+                                } catch (Exception 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;
+	}
+}

+ 1 - 1
src/com/mes/ui/MesClient.java

@@ -1527,7 +1527,7 @@ public class MesClient extends JFrame {
         JPanel inputPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
         JLabel label = new JLabel("客户码(二维码):");
         label.setFont(new Font("Microsoft YaHei UI", Font.PLAIN, 16));
-        JTextField snField = new JTextField(22);
+        JTextField snField = new JTextField("+KB60IS3031T729002101", 22);
         snField.setFont(new Font("Microsoft YaHei UI", Font.PLAIN, 16));
         snField.setPreferredSize(new Dimension(250, 35));
         inputPanel.add(label);

+ 1 - 1
src/resources/config/config.properties

@@ -1,4 +1,4 @@
-mes.gw=OP050
+mes.gw=OP060
 #mes.server_ip=127.0.0.1
 mes.server_ip=192.168.114.99
 mes.tcp_port=3000