wangxichen 2 روز پیش
والد
کامیت
7d625f2a2a

+ 3 - 0
.gitignore

@@ -7,3 +7,6 @@ release
 *.db
 *.iml
 data
+logs
+.lock
+mes-client.running

+ 20 - 20
config/label-config.json

@@ -11,10 +11,10 @@
       "id": "qrcode",
       "type": "qrcode",
       "label": "二维码",
-      "x": 70,
-      "y": 130,
-      "width": 100,
-      "height": 100,
+      "x": 40,
+      "y": 94,
+      "width": 168,
+      "height": 168,
       "eccLevel": "L",
       "cellWidth": "8",
       "text": "+KB60IS3031T811001201",
@@ -24,11 +24,11 @@
       "id": "product-line1",
       "type": "text",
       "label": "产品名",
-      "x": 313,
-      "y": 73,
-      "fontSize": 26,
+      "x": 283,
+      "y": 56,
+      "fontSize": 28,
       "fontFamily": "SimSun",
-      "fontStyle": 1,
+      "fontStyle": 0,
       "text": "高压电池下壳体总成",
       "dataSource": "static"
     },
@@ -36,10 +36,10 @@
       "id": "product-line2",
       "type": "text",
       "label": "产品型号",
-      "x": 344,
-      "y": 105,
-      "fontSize": 22,
-      "fontFamily": "Arial",
+      "x": 311,
+      "y": 86,
+      "fontSize": 28,
+      "fontFamily": "SimSun",
       "fontStyle": 0,
       "text": "2510094CDE2200",
       "dataSource": "productModel"
@@ -48,10 +48,10 @@
       "id": "date-serial",
       "type": "text",
       "label": "日期+流水号",
-      "x": 298,
-      "y": 204,
-      "fontSize": 24,
-      "fontFamily": "Arial",
+      "x": 250,
+      "y": 166,
+      "fontSize": 28,
+      "fontFamily": "SimSun",
       "fontStyle": 0,
       "text": "2026/08/11 NO:0000001",
       "dataSource": "dateSerial"
@@ -60,11 +60,11 @@
       "id": "batch-number",
       "type": "text",
       "label": "批次号",
-      "x": 275,
-      "y": 268,
+      "x": 250,
+      "y": 242,
       "fontSize": 28,
-      "fontFamily": "Arial",
-      "fontStyle": 1,
+      "fontFamily": "SimSun",
+      "fontStyle": 0,
       "text": "+KB60IS3031T811001201",
       "dataSource": "sn"
     }

+ 4 - 0
config/print.properties

@@ -1,5 +1,9 @@
 # ===== 扫码打标签(TSC 标签机)=====
 # 标签机 Windows 打印机名,需与「设备和打印机」里的名字完全一致(也可在程序里点 [标签机设置] 选择)
 label.printer.name=TSC TTP-244 PRO
+# 标签整体偏移(毫米,可填小数),换纸校准后位置还有偏差时调这里,也可在 [标签机设置] 里改
+# 上下:正数往下、负数往上;左右:正数往右、负数往左
+label.offset.y=0
+label.offset.x=0
 # 客户码校验正则(反斜杠写两个),默认只认 T09(+KB60)/T68(+KB87) 21 位客户码
 #label.sn.pattern=^\\+KB(60|87)[A-Z0-9]{8}[0-9]{8}$

+ 22 - 0
src/com/mes/label/LabelScanManager.java

@@ -23,6 +23,7 @@ import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.function.BiConsumer;
 import java.util.regex.Pattern;
 
 /**
@@ -128,6 +129,27 @@ public class LabelScanManager {
     }
 
     /**
+     * 换纸校准:排进打印线程,和打印串行(校准期间扫的码排在校准之后打)
+     * @param done 在打印线程回调 (成功?, 失败原因)
+     */
+    public void calibrate(BiConsumer<Boolean, String> done) {
+        executor.submit(() -> {
+            String printer = getLabelPrinterName();
+            try {
+                new TscPrinter().calibrate();
+            } catch (Throwable e) {
+                String msg = describe(e);
+                System.err.println("[标签] 换纸校准失败: " + msg);
+                AppLog.work("换纸校准", printer, false);
+                done.accept(false, msg);
+                return;
+            }
+            AppLog.work("换纸校准", printer, true);
+            done.accept(true, null);
+        });
+    }
+
+    /**
      * 启动自检:Java 位数、标签布局、TSCLIB.dll,通过返回 null,否则返回提示语
      * 会加载 DLL,不要在 EDT 调
      */

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

@@ -69,6 +69,17 @@ public class PrinterConfig {
 		return v == null ? defaultValue : v;
 	}
 
+	/** 读小数配置,没配或格式不对返回默认值 */
+	public static double getDouble(String key, double defaultValue) {
+		String v = get(key, "").trim();
+		if (v.isEmpty()) return defaultValue;
+		try {
+			return Double.parseDouble(v);
+		} catch (NumberFormatException e) {
+			return defaultValue;
+		}
+	}
+
 	/** 标签机 Windows 打印机名:label.printer.name,没配用默认 */
 	public static String getLabelPrinterName() {
 		String name = get("label.printer.name", "").trim();

+ 78 - 22
src/com/mes/print/tsc/TscPrinter.java

@@ -2,6 +2,7 @@ package com.mes.print.tsc;
 
 import com.mes.print.config.LabelConfigLoader;
 import com.mes.print.config.PrinterConfig;
+import com.mes.util.AppLog;
 
 import java.text.SimpleDateFormat;
 import java.util.Date;
@@ -27,6 +28,9 @@ import java.util.Map;
  */
 public class TscPrinter {
 
+    /** TTP-244 Pro 为 203 dpi,1mm ≈ 8 点 */
+    private static final double DOTS_PER_MM = 8.0;
+
     private final TSCLIB tsc = TSCLIB.INSTANCE;
     private final LabelConfigLoader configLoader;
 
@@ -64,26 +68,20 @@ public class TscPrinter {
         }
         // 先组装数据,数据有问题直接报错,不去占打印机端口
         Map<String, String> dataMap = prepareDataMap(sn.trim(), printDate == null ? new Date() : printDate);
+        // 整体偏移(print.properties 的 label.offset.x/y,毫米):换纸/校准后位置有偏差时现场微调
+        int offX = mmToDots(PrinterConfig.getDouble("label.offset.x", 0));
+        int offY = mmToDots(PrinterConfig.getDouble("label.offset.y", 0));
 
-        String printerName = PrinterConfig.getLabelPrinterName();
-        if (tsc.openport(printerName) != 1) {
-            throw new RuntimeException("打开标签机失败: " + printerName + "(检查 TSC 驱动是否安装、打印机名是否一致)");
-        }
+        String printerName = openPort();
         try {
-            LabelConfigLoader.LabelSize labelSize = configLoader.getLabelSize();
-            tsc.setup(
-                String.valueOf(labelSize.width),
-                String.valueOf(labelSize.totalHeight),
-                "4",  // 速度
-                "10", // 浓度
-                "0",  // 传感器类型
-                String.valueOf(labelSize.gap),
-                "0"   // 偏移
-            );
+            String setup = setupLabel();
             tsc.clearbuffer();
-            drawLabelFromConfig(dataMap);
+            drawLabelFromConfig(dataMap, offX, offY);
             tsc.printlabel("1", "1");  // 打印1份,每份1张
-            System.out.println("[TSC] 标签打印成功: " + sn + " NO:" + dataMap.get("serialNo") + " @" + printerName);
+            String info = "[TSC] 打印 " + sn + " NO:" + dataMap.get("serialNo") + " " + setup
+                    + " 偏移=(" + offX + "," + offY + ")点 打印机=" + printerName;
+            System.out.println(info);
+            AppLog.info(info);
         } catch (Exception e) {
             throw new RuntimeException("打印失败: " + e.getMessage(), e);
         } finally {
@@ -92,14 +90,72 @@ public class TscPrinter {
         }
     }
 
-    /** 按配置逐个绘制元素 */
-    private void drawLabelFromConfig(Map<String, String> dataMap) {
+    /**
+     * 换纸校准:按当前标签尺寸设置后发 GAPDETECT,打印机走纸自动测量标签长度和间隙。
+     * 会走出几张空白标签,校准结果保存在打印机里。
+     */
+    public void calibrate() {
+        String printerName = openPort();
+        try {
+            String setup = setupLabel();
+            tsc.sendcommand("GAPDETECT");
+            String info = "[TSC] 换纸校准 GAPDETECT " + setup + " 打印机=" + printerName;
+            System.out.println(info);
+            AppLog.info(info);
+        } catch (Exception e) {
+            throw new RuntimeException("校准失败: " + e.getMessage(), e);
+        } finally {
+            tsc.closeport();
+        }
+    }
+
+    /** 打开标签机端口,返回打印机名 */
+    private String openPort() {
+        String printerName = PrinterConfig.getLabelPrinterName();
+        if (tsc.openport(printerName) != 1) {
+            throw new RuntimeException("打开标签机失败: " + printerName + "(检查 TSC 驱动是否安装、打印机名是否一致)");
+        }
+        return printerName;
+    }
+
+    /**
+     * 下发标签尺寸/速度/浓度/间隙(来自 label-config.json 的 labelSize),返回参数描述(写日志用)
+     *
+     * 高度必须是标签本身高度(height=38.1),不能含间隙:
+     * 以前传 totalHeight(41.1=标签+间隙),打印长度正好等于一个标签节距,
+     * 校准准确后每打一张都会越过下一张的起点,打印机就跳过一张,出现打一张空一张。
+     */
+    private String setupLabel() {
+        LabelConfigLoader.LabelSize labelSize = configLoader.getLabelSize();
+        String width = String.valueOf(labelSize.width);
+        String height = String.valueOf(labelSize.height);
+        String gap = String.valueOf(labelSize.gap);
+        tsc.setup(
+            width,
+            height,
+            "4",  // 速度
+            "10", // 浓度
+            "0",  // 传感器类型:0=间隙
+            gap,
+            "0"   // 间隙偏移
+        );
+        return "SIZE=" + width + "x" + height + "mm GAP=" + gap + "mm";
+    }
+
+    private static int mmToDots(double mm) {
+        return (int) Math.round(mm * DOTS_PER_MM);
+    }
+
+    /** 按配置逐个绘制元素,offX/offY 为整体偏移(点) */
+    private void drawLabelFromConfig(Map<String, String> dataMap, int offX, int offY) {
         List<LabelConfigLoader.LabelElement> elements = configLoader.getElements();
         for (LabelConfigLoader.LabelElement element : elements) {
+            int x = Math.max(0, element.x + offX);
+            int y = Math.max(0, element.y + offY);
             if ("text".equals(element.type)) {
                 tsc.windowsfont(
-                    element.x,
-                    element.y,
+                    x,
+                    y,
                     element.fontSize,
                     0,  // rotation
                     element.fontStyle,
@@ -111,8 +167,8 @@ public class TscPrinter {
                 // qrcode() 函数不工作,用原始 TSPL:QRCODE x,y,eccLevel,cellWidth,mode,rotation,"data"
                 String qrCommand = String.format(
                     "QRCODE %d,%d,%s,%s,%s,%s,\"%s\"",
-                    element.x,
-                    element.y,
+                    x,
+                    y,
                     element.eccLevel,
                     element.cellWidth,
                     "A",  // mode

+ 83 - 10
src/com/mes/print/ui/PrinterSettingDialog.java

@@ -7,21 +7,29 @@ import javax.swing.*;
 import java.awt.*;
 
 /**
- * 标签机设置:选择 Windows 打印机名,保存到 config/print.properties 的 label.printer.name
- * 保存后立即生效,无需重启。
+ * 标签机设置,保存到 config/print.properties,保存后下一张立即生效,无需重启:
+ *   label.printer.name  Windows 打印机名
+ *   label.offset.y      整体上下偏移(毫米,正数往下)
+ *   label.offset.x      整体左右偏移(毫米,正数往右)
  */
 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 static final double OFFSET_MAX = 20;
 
 	private JComboBox<String> printerCombo;
+	private JSpinner offsetYSpinner;
+	private JSpinner offsetXSpinner;
 
 	public PrinterSettingDialog(Frame owner) {
 		super(owner, "标签机设置", true);
 		buildUI();
 		reloadPrinters();
 		printerCombo.setSelectedItem(currentConfigName());
+		offsetYSpinner.setValue(currentOffset("label.offset.y"));
+		offsetXSpinner.setValue(currentOffset("label.offset.x"));
 		pack();
 		setLocationRelativeTo(owner);
 	}
@@ -32,13 +40,12 @@ public class PrinterSettingDialog extends JDialog {
 		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.insets = new Insets(8, 8, 8, 8);
 		c.anchor = GridBagConstraints.WEST;
 
-		JLabel label = new JLabel("标签机:");
-		label.setFont(FONT_LABEL);
+		// 标签机
 		c.gridx = 0; c.gridy = 0;
-		center.add(label, c);
+		center.add(newLabel("标签机:"), c);
 
 		printerCombo = new JComboBox<>();
 		printerCombo.setEditable(true);
@@ -54,10 +61,24 @@ public class PrinterSettingDialog extends JDialog {
 		c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0;
 		center.add(printerPanel, c);
 
-		JLabel hint = new JLabel("<html><span style='color:gray'>下拉列出本机已安装的打印机,请选 TSC 驱动对应的那一项(名字需与系统里完全一致)</span></html>");
-		hint.setFont(new Font("微软雅黑", Font.PLAIN, 12));
 		c.gridx = 0; c.gridy = 1; c.gridwidth = 2;
-		center.add(hint, c);
+		center.add(newHint("下拉列出本机已安装的打印机,请选 TSC 驱动对应的那一项(名字需与系统里完全一致)"), c);
+
+		// 偏移
+		c.gridwidth = 1; c.fill = GridBagConstraints.NONE; c.weightx = 0;
+		offsetYSpinner = newOffsetSpinner();
+		offsetXSpinner = newOffsetSpinner();
+		c.gridx = 0; c.gridy = 2;
+		center.add(newLabel("上下偏移:"), c);
+		c.gridx = 1;
+		center.add(withUnit(offsetYSpinner, "毫米(正数往下,负数往上)"), c);
+		c.gridx = 0; c.gridy = 3;
+		center.add(newLabel("左右偏移:"), c);
+		c.gridx = 1;
+		center.add(withUnit(offsetXSpinner, "毫米(正数往右,负数往左)"), c);
+
+		c.gridx = 0; c.gridy = 4; c.gridwidth = 2; c.fill = GridBagConstraints.HORIZONTAL;
+		center.add(newHint("先点 [换纸校准],打一张看位置;整体偏了多少毫米就在这里填多少,保存后下一张生效"), c);
 
 		add(center, BorderLayout.CENTER);
 
@@ -70,12 +91,40 @@ public class PrinterSettingDialog extends JDialog {
 		add(bottom, BorderLayout.SOUTH);
 	}
 
+	private JLabel newLabel(String text) {
+		JLabel l = new JLabel(text);
+		l.setFont(FONT_LABEL);
+		return l;
+	}
+
+	private JLabel newHint(String text) {
+		JLabel hint = new JLabel("<html><span style='color:gray'>" + text + "</span></html>");
+		hint.setFont(new Font("微软雅黑", Font.PLAIN, 12));
+		return hint;
+	}
+
 	private JButton newButton(String text) {
 		JButton b = new JButton(text);
 		b.setFont(FONT_INPUT);
 		return b;
 	}
 
+	private JSpinner newOffsetSpinner() {
+		JSpinner s = new JSpinner(new SpinnerNumberModel(0.0, -OFFSET_MAX, OFFSET_MAX, 0.5));
+		s.setFont(FONT_INPUT);
+		s.setPreferredSize(new Dimension(100, 32));
+		return s;
+	}
+
+	private JPanel withUnit(JComponent comp, String unit) {
+		JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
+		p.add(comp);
+		JLabel l = new JLabel(unit);
+		l.setFont(FONT_INPUT);
+		p.add(l);
+		return p;
+	}
+
 	private void reloadPrinters() {
 		Object cur = printerCombo.getSelectedItem();
 		printerCombo.removeAllItems();
@@ -91,6 +140,16 @@ public class PrinterSettingDialog extends JDialog {
 		}
 	}
 
+	/** 当前偏移,超出范围的截断,避免 SpinnerNumberModel 抛异常 */
+	private static double currentOffset(String key) {
+		try {
+			double v = PrinterConfig.getDouble(key, 0);
+			return Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, v));
+		} catch (Exception e) {
+			return 0;
+		}
+	}
+
 	private void doSave() {
 		Object v = printerCombo.getSelectedItem();
 		String name = v == null ? "" : v.toString().trim();
@@ -99,8 +158,22 @@ public class PrinterSettingDialog extends JDialog {
 			return;
 		}
 		try {
+			// 手动输入的数字要先提交,否则取到的是旧值
+			offsetYSpinner.commitEdit();
+			offsetXSpinner.commitEdit();
+		} catch (java.text.ParseException ex) {
+			JOptionPane.showMessageDialog(this, "偏移请填数字(-20 ~ 20)", "提示", JOptionPane.INFORMATION_MESSAGE);
+			return;
+		}
+		double offY = ((Number) offsetYSpinner.getValue()).doubleValue();
+		double offX = ((Number) offsetXSpinner.getValue()).doubleValue();
+		try {
 			PrinterConfig.set("label.printer.name", name);
-			JOptionPane.showMessageDialog(this, "标签机已保存: " + name, "提示", JOptionPane.INFORMATION_MESSAGE);
+			PrinterConfig.set("label.offset.y", String.valueOf(offY));
+			PrinterConfig.set("label.offset.x", String.valueOf(offX));
+			JOptionPane.showMessageDialog(this,
+					"已保存\n标签机: " + name + "\n上下偏移: " + offY + " 毫米\n左右偏移: " + offX + " 毫米",
+					"提示", JOptionPane.INFORMATION_MESSAGE);
 			dispose();
 		} catch (Exception ex) {
 			JOptionPane.showMessageDialog(this, "保存失败:" + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);

+ 106 - 16
src/com/mes/ui/MesClient.java

@@ -26,6 +26,8 @@ import java.util.concurrent.TimeUnit;
  * 扫码打标签 —— 主界面
  *
  * 扫客户码 → 直接打 TSC 标签(TSCLIB.dll),不调服务端、不提交工位结果。
+ * 补打:再扫一次同一个码,或在「最近记录」里选中后点 [补打选中]。
+ * 换纸:装好纸后点 [换纸校准],让打印机重新测量标签间隙。
  */
 public class MesClient extends JFrame {
 
@@ -33,6 +35,20 @@ public class MesClient extends JFrame {
     private static final Color OK_COLOR = new Color(0, 130, 0);
     private static final Color BUSY_COLOR = new Color(60, 60, 200);
 
+    /** 最近记录一行:显示文本 + 对应客户码(补打用) */
+    private static final class RecentItem {
+        final String sn;
+        final String text;
+
+        RecentItem(String sn, String text) {
+            this.sn = sn;
+            this.text = text;
+        }
+
+        @Override
+        public String toString() { return text; }
+    }
+
     private volatile LabelScanManager labelMgr;
     private ScheduledExecutorService statusRefresher;
 
@@ -40,16 +56,18 @@ public class MesClient extends JFrame {
     private JLabel resultLabel;
     private JLabel printerLabel;
     private JLabel statsLabel;
-    private final DefaultListModel<String> recentModel = new DefaultListModel<>();
+    private JButton calibrateBtn;
+    private JList<RecentItem> recentList;
+    private final DefaultListModel<RecentItem> recentModel = new DefaultListModel<>();
 
     // 本次运行计数,只在 EDT 改
     private int okCount = 0;
     private int failCount = 0;
 
     public MesClient() {
-        super("扫码打标签 v2.0");
+        super("扫码打标签 v2.1");
         setDefaultCloseOperation(EXIT_ON_CLOSE);
-        setSize(760, 560);
+        setSize(820, 580);
         setLocationRelativeTo(null);
 
         JPanel root = new JPanel(new BorderLayout(8, 8));
@@ -80,11 +98,9 @@ public class MesClient extends JFrame {
         JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
         statsLabel = new JLabel("成功 0 / 失败 0");
         right.add(statsLabel);
-        JButton settingBtn = new JButton("标签机设置");
-        // 按钮不抢焦点:按钮有焦点时扫码枪的回车会触发按钮
-        settingBtn.setFocusable(false);
-        settingBtn.addActionListener(e -> openPrinterSetting());
-        right.add(settingBtn);
+        calibrateBtn = newButton("换纸校准", e -> onCalibrate());
+        right.add(calibrateBtn);
+        right.add(newButton("标签机设置", e -> openPrinterSetting()));
         p.add(right, BorderLayout.EAST);
         return p;
     }
@@ -115,15 +131,35 @@ public class MesClient extends JFrame {
         north.add(resultLabel, BorderLayout.SOUTH);
         p.add(north, BorderLayout.NORTH);
 
-        JList<String> recentList = new JList<>(recentModel);
+        recentList = new JList<>(recentModel);
+        // 不可聚焦:鼠标点选仍可用,但焦点留在扫码框
         recentList.setFocusable(false);
+        recentList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
         recentList.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13));
         JScrollPane sp = new JScrollPane(recentList);
-        sp.setBorder(new TitledBorder("最近记录"));
-        p.add(sp, BorderLayout.CENTER);
+
+        JPanel recentBar = new JPanel(new BorderLayout(6, 0));
+        JLabel hint = new JLabel("补打:再扫一次同一个码,或选中下面一条后点右边按钮");
+        hint.setForeground(Color.GRAY);
+        recentBar.add(hint, BorderLayout.CENTER);
+        recentBar.add(newButton("补打选中", e -> reprintSelected()), BorderLayout.EAST);
+
+        JPanel recentPanel = new JPanel(new BorderLayout(0, 4));
+        recentPanel.setBorder(new TitledBorder("最近记录(本次运行)"));
+        recentPanel.add(recentBar, BorderLayout.NORTH);
+        recentPanel.add(sp, BorderLayout.CENTER);
+        p.add(recentPanel, BorderLayout.CENTER);
         return p;
     }
 
+    /** 按钮统一不抢焦点:按钮有焦点时扫码枪的回车会触发按钮 */
+    private static JButton newButton(String text, java.awt.event.ActionListener action) {
+        JButton b = new JButton(text);
+        b.setFocusable(false);
+        b.addActionListener(action);
+        return b;
+    }
+
     // ============ 生命周期 ============
 
     /** 初始化业务组件(构造完 UI 后调用) */
@@ -181,9 +217,27 @@ public class MesClient extends JFrame {
     private void onScan() {
         String sn = LabelScanManager.normalize(scanField.getText());
         scanField.setText("");
-        if (sn.isEmpty() || labelMgr == null) return;
+        if (sn.isEmpty()) return;
         AppLog.scan("标签", sn);
+        requestPrint(sn);
+    }
+
+    /** 最近记录里选中一条补打(EDT) */
+    private void reprintSelected() {
+        RecentItem item = recentList.getSelectedValue();
+        if (item == null) {
+            showResult(false, "✖ 请先在「最近记录」里选中要补打的一条");
+        } else if (item.sn == null) {
+            showResult(false, "✖ 选中的不是打印记录,请选带客户码的那条");
+        } else {
+            requestPrint(item.sn);
+        }
+        focusScan();
+    }
 
+    /** 扫码和补打共用:校验 → 防重 → 打过的弹补打确认 → 提交(EDT) */
+    private void requestPrint(String sn) {
+        if (labelMgr == null) return;
         String err = labelMgr.validate(sn);
         if (err != null) {
             showResult(false, "✖ " + err);
@@ -234,18 +288,53 @@ public class MesClient extends JFrame {
             if (ok) okCount++; else failCount++;
             statsLabel.setText("成功 " + okCount + " / 失败 " + failCount);
             showResult(ok && msg == null, text);
-            addRecent(text);
+            addRecent(sn, text);
         });
     }
 
+    // ============ 换纸校准 ============
+
+    /**
+     * 换纸校准(EDT)。默认按钮是「取消」,防止弹框时扫码枪回车误触发走纸
+     */
+    private void onCalibrate() {
+        if (labelMgr == null) return;
+        String msg = "请确认:\n"
+                + "1. 新纸已装好,两边导纸挡板靠紧,标签从传感器下穿过\n"
+                + "2. 机盖已合上,标签机绿灯亮\n\n"
+                + "校准时标签机会自动走出几张空白标签,属正常现象。\n"
+                + "完成后扫码(或补打)一张确认位置。开始校准?";
+        Object[] options = {"开始校准", "取消"};
+        int r = JOptionPane.showOptionDialog(this, msg, "换纸校准",
+                JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, options[1]);
+        focusScan();
+        if (r != 0) return;
+
+        calibrateBtn.setEnabled(false);
+        resultLabel.setText("⋯ 正在校准,标签机会走出几张空白标签...");
+        resultLabel.setForeground(BUSY_COLOR);
+        labelMgr.calibrate((ok, err) -> SwingUtilities.invokeLater(() -> {
+            calibrateBtn.setEnabled(true);
+            String text = ok
+                    ? "✔ 已发送校准,等标签机停止走纸后扫码打一张确认位置"
+                    : "✖ 校准失败: " + err;
+            showResult(ok, text);
+            addRecent(null, text);
+        }));
+    }
+
+    // ============ 通用 ============
+
     private void showResult(boolean ok, String text) {
         resultLabel.setText(text);
         resultLabel.setForeground(ok ? OK_COLOR : Color.RED);
         if (!ok) Toolkit.getDefaultToolkit().beep();
     }
 
-    private void addRecent(String text) {
-        recentModel.add(0, new SimpleDateFormat("HH:mm:ss").format(new Date()) + "  " + text);
+    /** sn 为 null 表示非打印记录(如校准),补打时会提示无效 */
+    private void addRecent(String sn, String text) {
+        String line = new SimpleDateFormat("HH:mm:ss").format(new Date()) + "  " + text;
+        recentModel.add(0, new RecentItem(sn, line));
         while (recentModel.size() > RECENT_MAX) recentModel.remove(recentModel.size() - 1);
     }
 
@@ -272,7 +361,8 @@ public class MesClient extends JFrame {
             File dir = cfg.getAbsoluteFile().getParentFile();
             if (dir != null && !dir.exists()) dir.mkdirs();
             try (Writer w = new OutputStreamWriter(new FileOutputStream(cfg), StandardCharsets.UTF_8)) {
-                w.write("# 标签机 Windows 打印机名\r\nlabel.printer.name=TSC TTP-244 PRO\r\n");
+                w.write("# 标签机 Windows 打印机名\r\nlabel.printer.name=TSC TTP-244 PRO\r\n"
+                        + "# 标签整体偏移(毫米):上下正数往下、左右正数往右\r\nlabel.offset.y=0\r\nlabel.offset.x=0\r\n");
             }
             System.out.println("[MesClient] 已创建默认配置: " + cfg.getAbsolutePath());
         } catch (Exception e) {

+ 5 - 0
src/com/mes/util/AppLog.java

@@ -82,6 +82,11 @@ public final class AppLog {
                 + ",操作=" + clean(action) + ",编码=" + clean(code), null);
     }
 
+    /** 通用信息日志(打印参数等排查用) */
+    public static void info(String message) {
+        log("INFO", clean(message), null);
+    }
+
     public static void markUserClose() { exitReason = "USER_CLOSE"; }
 
     private static void writeState() {