wangxichen 16 hours ago
parent
commit
567f282b90

BIN
09b09c01ff6547de543b7fa51f50dce2.png


BIN
lib/snakeyaml-2.0.jar


+ 164 - 0
src/com/mes/component/StationPanel.java

@@ -0,0 +1,164 @@
+package com.mes.component;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * 氦检工位面板:读码回显 + 流程状态 + 本底/漏率 + 检测结果
+ * 视觉沿用原客户端风格:白底、黑底彩字状态、原图标
+ * 所有 set 方法可在任意线程调用,内部已切到 EDT
+ */
+public class StationPanel extends JPanel {
+
+    // 状态文字色,沿用原顶栏绿/红
+    public static final Color STATE_WAIT = new Color(0xBD, 0xBD, 0xBD);
+    public static final Color STATE_BUSY = new Color(0xFF, 0xD5, 0x4F);
+    public static final Color STATE_OK = Color.GREEN;
+    public static final Color STATE_NG = Color.RED;
+    public static final Color STATE_RUN = new Color(0x4F, 0xC3, 0xF7);
+
+    private static final Icon DOT_GREEN = new ImageIcon(StationPanel.class.getResource("/bg/green_dot.png"));
+    private static final Icon DOT_GREY = new ImageIcon(StationPanel.class.getResource("/bg/grey_dot.png"));
+    private static final Icon ICON_OK = new ImageIcon(StationPanel.class.getResource("/bg/ok_bg.png"));
+    private static final Icon ICON_NG = new ImageIcon(StationPanel.class.getResource("/bg/ng_bg.png"));
+
+    private final JLabel lampLabel;
+    private final JTextField codeField;
+    private final JLabel stateLabel;
+    private final JLabel baseLabel;
+    private final JLabel leakLabel;
+    private final JLabel resultLabel;
+    private final JLabel retryLabel;
+
+    public StationPanel(String title) {
+        setLayout(null);
+        setBackground(Color.WHITE);
+
+        JLabel titleLabel = new JLabel(title);
+        titleLabel.setFont(new Font("微软雅黑", Font.PLAIN, 24));
+        titleLabel.setBounds(20, 4, 340, 36);
+        add(titleLabel);
+
+        // PLC 信号灯
+        lampLabel = new JLabel(DOT_GREY);
+        lampLabel.setBounds(400, 4, 36, 36);
+        add(lampLabel);
+
+        // 读码回显框:沿用原扫码框样式,只读、居中
+        codeField = new JTextField();
+        codeField.setHorizontalAlignment(SwingConstants.CENTER);
+        codeField.setEditable(false);
+        codeField.setFont(new Font("微软雅黑", Font.PLAIN, 28));
+        codeField.setBounds(20, 110, 420, 52);
+        codeField.setText("--");
+        add(codeField);
+
+        stateLabel = new JLabel("等待扫码", SwingConstants.CENTER);
+        stateLabel.setFont(new Font("微软雅黑", Font.BOLD, 32));
+        stateLabel.setForeground(STATE_WAIT);
+        stateLabel.setOpaque(true);
+        stateLabel.setBackground(Color.BLACK);
+        stateLabel.setBounds(20, 44, 420, 62);
+        add(stateLabel);
+
+        JLabel baseTitle = new JLabel("本底");
+        baseTitle.setFont(new Font("微软雅黑", Font.PLAIN, 22));
+        baseTitle.setForeground(new Color(0x61, 0x61, 0x61));
+        baseTitle.setBounds(30, 174, 100, 34);
+        add(baseTitle);
+
+        baseLabel = new JLabel("--");
+        baseLabel.setFont(new Font("微软雅黑", Font.BOLD, 26));
+        baseLabel.setBounds(130, 174, 300, 34);
+        add(baseLabel);
+
+        JLabel leakTitle = new JLabel("漏率");
+        leakTitle.setFont(new Font("微软雅黑", Font.PLAIN, 22));
+        leakTitle.setForeground(new Color(0x61, 0x61, 0x61));
+        leakTitle.setBounds(30, 212, 100, 34);
+        add(leakTitle);
+
+        leakLabel = new JLabel("--");
+        leakLabel.setFont(new Font("微软雅黑", Font.BOLD, 26));
+        leakLabel.setBounds(130, 212, 300, 34);
+        add(leakLabel);
+
+        resultLabel = new JLabel("--", SwingConstants.CENTER);
+        resultLabel.setFont(new Font("微软雅黑", Font.BOLD, 32));
+        resultLabel.setForeground(new Color(0xBD, 0xBD, 0xBD));
+        resultLabel.setIconTextGap(12);
+        resultLabel.setBounds(110, 252, 240, 80);
+        add(resultLabel);
+
+        retryLabel = new JLabel("该工件为返修件,请仔细检查", SwingConstants.CENTER);
+        retryLabel.setFont(new Font("微软雅黑", Font.BOLD, 22));
+        retryLabel.setForeground(Color.RED);
+        retryLabel.setBounds(20, 336, 420, 32);
+        retryLabel.setVisible(false);
+        add(retryLabel);
+    }
+
+    /** PLC 信号灯:true 亮绿,false 灰 */
+    public void setLamp(boolean on) {
+        SwingUtilities.invokeLater(() -> lampLabel.setIcon(on ? DOT_GREEN : DOT_GREY));
+    }
+
+    public void setCode(String code) {
+        SwingUtilities.invokeLater(() ->
+                codeField.setText(code == null || code.isEmpty() ? "--" : code));
+    }
+
+    /** color 用 STATE_xxx 常量,同一色调的文字显示在黑底上 */
+    public void setState(String text, Color color) {
+        SwingUtilities.invokeLater(() -> {
+            stateLabel.setText(text);
+            stateLabel.setForeground(color);
+        });
+    }
+
+    public void setData(String base, String leak) {
+        SwingUtilities.invokeLater(() -> {
+            baseLabel.setText(base == null || base.isEmpty() ? "--" : base);
+            leakLabel.setText(leak == null || leak.isEmpty() ? "--" : leak);
+        });
+    }
+
+    /** result 传 OK / NG,其他值显示 -- */
+    public void setResult(String result) {
+        SwingUtilities.invokeLater(() -> {
+            if ("OK".equalsIgnoreCase(result)) {
+                resultLabel.setIcon(ICON_OK);
+                resultLabel.setText("OK");
+                resultLabel.setForeground(STATE_OK);
+            } else if ("NG".equalsIgnoreCase(result)) {
+                resultLabel.setIcon(ICON_NG);
+                resultLabel.setText("NG");
+                resultLabel.setForeground(STATE_NG);
+            } else {
+                resultLabel.setIcon(null);
+                resultLabel.setText("--");
+                resultLabel.setForeground(STATE_WAIT);
+            }
+        });
+    }
+
+    public void setRetry(boolean show) {
+        SwingUtilities.invokeLater(() -> retryLabel.setVisible(show));
+    }
+
+    /** 复位到等待扫码 */
+    public void reset() {
+        SwingUtilities.invokeLater(() -> {
+            codeField.setText("--");
+            baseLabel.setText("--");
+            leakLabel.setText("--");
+            stateLabel.setText("等待扫码");
+            stateLabel.setForeground(STATE_WAIT);
+            resultLabel.setIcon(null);
+            resultLabel.setText("--");
+            resultLabel.setForeground(STATE_WAIT);
+            retryLabel.setVisible(false);
+            lampLabel.setIcon(DOT_GREY);
+        });
+    }
+}

+ 199 - 0
src/com/mes/core/ConditionInterceptor.java

@@ -0,0 +1,199 @@
+package com.mes.core;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.Field;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 条件拦截器 - 拦截条件变量的设置,支持持续控制
+ * 
+ * 功能:
+ * 1. 注册持续控制规则(某个变量持续保持某个值)
+ * 2. 在 setter 被调用时检查是否有拦截规则
+ * 3. 如果有规则,则强制使用规则指定的值
+ */
+public class ConditionInterceptor {
+    private static final Logger log = LoggerFactory.getLogger(ConditionInterceptor.class);
+    
+    // 持续控制规则: fieldName -> 强制值
+    private final Map<String, Object> persistentRules = new ConcurrentHashMap<>();
+    
+    // 拦截监听器
+    private InterceptListener listener;
+    
+    /**
+     * 拦截监听器接口
+     */
+    public interface InterceptListener {
+        void onIntercept(String fieldName, Object originalValue, Object forcedValue);
+    }
+    
+    /**
+     * 添加持续控制规则
+     * @param fieldName 字段名
+     * @param value 强制值
+     */
+    public void addRule(String fieldName, Object value) {
+        persistentRules.put(fieldName, value);
+        log.info("[ConditionInterceptor] 添加规则: {} = {}", fieldName, value);
+    }
+    
+    /**
+     * 移除持续控制规则
+     * @param fieldName 字段名
+     */
+    public void removeRule(String fieldName) {
+        persistentRules.remove(fieldName);
+        log.info("[ConditionInterceptor] 移除规则: {}", fieldName);
+    }
+    
+    /**
+     * 清空所有规则
+     */
+    public void clearRules() {
+        persistentRules.clear();
+        log.info("[ConditionInterceptor] 清空所有规则");
+    }
+    
+    /**
+     * 检查是否有规则
+     */
+    public boolean hasRule(String fieldName) {
+        return persistentRules.containsKey(fieldName);
+    }
+    
+    /**
+     * 获取规则值
+     */
+    public Object getRuleValue(String fieldName) {
+        return persistentRules.get(fieldName);
+    }
+    
+    /**
+     * 获取所有规则
+     */
+    public Map<String, Object> getAllRules() {
+        return Collections.unmodifiableMap(persistentRules);
+    }
+    
+    /**
+     * 拦截并返回最终值
+     * 如果有持续控制规则,返回规则值;否则返回原始值
+     * 
+     * @param fieldName 字段名
+     * @param originalValue 原始值
+     * @return 最终值
+     */
+    @SuppressWarnings("unchecked")
+    public <T> T intercept(String fieldName, T originalValue) {
+        if (persistentRules.containsKey(fieldName)) {
+            Object forcedValue = persistentRules.get(fieldName);
+            if (listener != null) {
+                listener.onIntercept(fieldName, originalValue, forcedValue);
+            }
+            log.debug("[ConditionInterceptor] 拦截 {}: {} -> {}", fieldName, originalValue, forcedValue);
+            return (T) forcedValue;
+        }
+        return originalValue;
+    }
+    
+    /**
+     * 设置监听器
+     */
+    public void setListener(InterceptListener listener) {
+        this.listener = listener;
+    }
+    
+    // ========== 条件元数据扫描 ==========
+    
+    /**
+     * 条件元数据
+     */
+    public static class ConditionMeta {
+        private final String fieldName;
+        private final String name;
+        private final String description;
+        private final String group;
+        private final String[] affectedSteps;
+        private final Class<?> type;
+        private final boolean controllable;
+        
+        public ConditionMeta(Field field, WorkflowCondition annotation) {
+            this.fieldName = field.getName();
+            this.name = annotation.name();
+            this.description = annotation.description();
+            this.group = annotation.group();
+            this.affectedSteps = annotation.affectedSteps();
+            this.type = field.getType();
+            this.controllable = annotation.controllable();
+        }
+        
+        public String getFieldName() { return fieldName; }
+        public String getName() { return name; }
+        public String getDescription() { return description; }
+        public String getGroup() { return group; }
+        public String[] getAffectedSteps() { return affectedSteps; }
+        public Class<?> getType() { return type; }
+        public boolean isControllable() { return controllable; }
+        
+        public boolean isBoolean() { 
+            return type == boolean.class || type == Boolean.class; 
+        }
+        
+        public boolean isNumeric() {
+            return type == int.class || type == Integer.class ||
+                   type == long.class || type == Long.class ||
+                   type == double.class || type == Double.class;
+        }
+    }
+    
+    /**
+     * 扫描类中所有带 @WorkflowCondition 注解的字段
+     */
+    public static List<ConditionMeta> scanConditions(Class<?> clazz) {
+        List<ConditionMeta> conditions = new ArrayList<>();
+        
+        for (Field field : clazz.getDeclaredFields()) {
+            WorkflowCondition annotation = field.getAnnotation(WorkflowCondition.class);
+            if (annotation != null) {
+                conditions.add(new ConditionMeta(field, annotation));
+            }
+        }
+        
+        // 按分组排序
+        conditions.sort(Comparator.comparing(ConditionMeta::getGroup)
+                .thenComparing(ConditionMeta::getName));
+        
+        return conditions;
+    }
+    
+    /**
+     * 通过反射获取字段值
+     */
+    public static Object getFieldValue(Object obj, String fieldName) {
+        try {
+            Field field = obj.getClass().getDeclaredField(fieldName);
+            field.setAccessible(true);
+            return field.get(obj);
+        } catch (Exception e) {
+            log.error("获取字段值失败: {}", fieldName, e);
+            return null;
+        }
+    }
+    
+    /**
+     * 通过反射设置字段值
+     */
+    public static void setFieldValue(Object obj, String fieldName, Object value) {
+        try {
+            Field field = obj.getClass().getDeclaredField(fieldName);
+            field.setAccessible(true);
+            field.set(obj, value);
+        } catch (Exception e) {
+            log.error("设置字段值失败: {}", fieldName, e);
+        }
+    }
+}

+ 158 - 0
src/com/mes/core/OprnoRegistry.java

@@ -0,0 +1,158 @@
+package com.mes.core;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 工位号注册表 —— 按产线位置(P1-P4)维护工位号到工位名称的映射
+ * 切换产线位置后工位列表跟着切换
+ * 注:P1 和 P2 工位列表完全一致
+ */
+public final class OprnoRegistry {
+
+    /** P1/P2 共用工位表 */
+    private static final Map<String, String> P1P2;
+    /** P3 工位表 (AY7-520) */
+    private static final Map<String, String> P3;
+    /** P4 工位表 (P04-1) */
+    private static final Map<String, String> P4;
+
+    static {
+        // P1/P2 (P02-1 / AY7-610)
+        Map<String, String> p = new LinkedHashMap<>();
+        p.put("OP040", "镭雕二维码");
+        p.put("OP060", "框架CMT焊接");
+        p.put("OP070", "人工补焊");
+        p.put("OP080", "焊道检验");
+        p.put("OP090", "焊道打磨");
+        p.put("OP110", "框架气密");
+        p.put("OP120", "框架反面CNC加工+去毛刺");
+        p.put("OP130", "框架反面吹铝屑+清洁");
+        p.put("OP140", "框架涂胶");
+        p.put("OP150", "液冷板安装+水冷板点焊");
+        p.put("OP170", "液冷板FSW");
+        p.put("OP190", "匙孔补焊+匙孔补焊打磨");
+        p.put("OP200", "总成正面CNC+去毛刺");
+        p.put("OP210", "总成正面吹铝屑+清洁");
+        p.put("OP220", "总成正面装配");
+        p.put("OP230", "套筒涂胶");
+        p.put("OP250", "左右边梁封堵");
+        p.put("OP260", "半成品气密");
+        p.put("OP270", "VHB胶带粘贴+冷板泡棉粘贴");
+        p.put("OP280", "冷板背面涂胶");
+        p.put("OP290", "底护板清洗");
+        p.put("OP300", "安装底护板");
+        p.put("OP310", "成品气密");
+        p.put("OP320", "液冷板气密");
+        p.put("OP330", "总成检具检验");
+        p.put("OP340", "总成清洁");
+        p.put("OP350", "箱体封堵");
+        p.put("OP360", "粘贴云母片");
+        p.put("OP380", "终检");
+        p.put("OP390", "GP12正面");
+        p.put("OP400", "后梁底涂镭雕");
+        p.put("OP420", "冷板校验");
+        P1P2 = Collections.unmodifiableMap(p);
+
+        // P3 (AY7-520)
+        Map<String, String> p3 = new LinkedHashMap<>();
+        p3.put("OP040", "镭雕二维码");
+        p3.put("OP060", "CMT框架焊接");
+        p3.put("OP070", "框架总成补焊");
+        p3.put("OP080", "焊道检验");
+        p3.put("OP090", "焊道打磨");
+        p3.put("OP110", "框架气密");
+        p3.put("OP120", "框架反面CNC加工");
+        p3.put("OP130", "框架反面吹铝屑+清洁");
+        p3.put("OP140", "框架涂胶");
+        p3.put("OP150", "液冷板安装+激光点固");
+        p3.put("OP170", "液冷板FSW");
+        p3.put("OP190", "匙孔补焊+匙孔补焊打磨");
+        p3.put("OP200", "总成正面CNC+去毛刺");
+        p3.put("OP210", "总成正面吹铝屑+清洁");
+        p3.put("OP220", "总成正面装配");
+        p3.put("OP230", "套筒涂胶");
+        p3.put("OP250", "左右边梁封堵");
+        p3.put("OP260", "半成品气密");
+        p3.put("OP270", "VHB胶带粘贴+冷板泡棉粘贴");
+        p3.put("OP280", "冷板背面涂胶");
+        p3.put("OP290", "底护板清洗");
+        p3.put("OP300", "安装底护板");
+        p3.put("OP310", "成品气密");
+        p3.put("OP320", "液冷板气密");
+        p3.put("OP330", "总成检具检验");
+        p3.put("OP340", "总成清洁");
+        p3.put("OP350", "箱体封堵");
+        p3.put("OP360", "粘贴云母片");
+        p3.put("OP380", "终检");
+        p3.put("OP390", "GP12拍照");
+        p3.put("OP400", "后梁底涂镭雕");
+        p3.put("OP420", "冷板校验");
+        P3 = Collections.unmodifiableMap(p3);
+
+        // P4 (P04-1) 注:原始数据里 OP220 与 OP300 出现了重名,按出现顺序去重
+        Map<String, String> p4 = new LinkedHashMap<>();
+        p4.put("OP060", "CMT框架焊接");
+        p4.put("OP100", "框架气密");
+        p4.put("OP110", "框架反面CNC");
+        p4.put("OP140", "液冷板安装+激光点固");
+        p4.put("OP160", "液冷板FSW");
+        p4.put("OP200", "总成正面CNC");
+        p4.put("OP220", "总成正面装配");
+        p4.put("OP260", "半成品气密");
+        p4.put("OP300", "安装底护板");
+        p4.put("OP320", "液冷板气密");
+        p4.put("OP350", "拉铆装配");
+        p4.put("OP370", "CCD检测");
+        p4.put("OP360", "总成检具");
+        p4.put("OP410", "下线");
+        P4 = Collections.unmodifiableMap(p4);
+    }
+
+    private OprnoRegistry() {}
+
+    /** 格式化工位号:6位带 A/B 后缀的截取为5位(与 mesclient-okng.formatOprno 一致) */
+    public static String formatOprno(String code) {
+        if (code == null) return "";
+        String c = code.trim();
+        if (c.length() == 6) {
+            return c.substring(0, 5);
+        }
+        return c;
+    }
+
+    /**
+     * 根据产线位置id查工位名称
+     * @return 匹配返回中文名,查不到返回空字符串
+     */
+    public static String getGwDes(String lineId, String code) {
+        if (lineId == null || code == null) return "";
+        Map<String, String> table = tableOf(lineId);
+        if (table == null) return "";
+        return table.getOrDefault(formatOprno(code), "");
+    }
+
+    /** 获取指定产线位置的工位码列表(UI下拉用) */
+    public static List<String> listCodes(String lineId) {
+        Map<String, String> table = tableOf(lineId);
+        if (table == null) return Collections.emptyList();
+        return new java.util.ArrayList<>(table.keySet());
+    }
+
+    private static Map<String, String> tableOf(String lineId) {
+        if (lineId == null) return null;
+        switch (lineId.toUpperCase()) {
+            case "P1":
+            case "P2":
+                return P1P2;
+            case "P3":
+                return P3;
+            case "P4":
+                return P4;
+            default:
+                return null;
+        }
+    }
+}

+ 854 - 0
src/com/mes/core/ProjectConfigManager.java

@@ -0,0 +1,854 @@
+package com.mes.core;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONWriter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 项目配置管理器
+ * 负责切换"产线位置"和"产品号"(两者互相独立、可自由组合)
+ * 以及拉铆设备IP的运行时持久化
+ *
+ * 产线位置(Line):决定工位号→工序名称的映射表(配合 OprnoRegistry 使用)
+ * 产品号(Product):决定工件码/冷板码/底护板码前缀
+ *
+ * 两张表内置,避免误改;运行时可变的仅"当前产线位置id"、"当前产品号id"和"设备IP"
+ */
+public class ProjectConfigManager {
+    private static final Logger log = LoggerFactory.getLogger(ProjectConfigManager.class);
+
+    private static final String CONFIG_DIR = "config";
+    private static final String CONFIG_FILE = "project_config.json";
+
+    // ========== 产线位置定义 ==========
+    public static final class Line {
+        private final String id;   // P1/P2/P3/P4
+
+        public Line(String id) {
+            this.id = id;
+        }
+
+        public String getId() { return id; }
+
+        /** 供UI下拉显示 */
+        public String getDisplay() { return id; }
+    }
+
+    // ========== 产品号定义 ==========
+    public static final class Product {
+        private final String id;                      // P02-1/AY7-610/AY7-520/P04-1/650
+        private final String[] productPrefixes;       // 工件码(框架码) 前缀白名单
+        private final String[] coldPlatePrefixes;     // 冷板码 前缀白名单
+        private final String[] bottomPlatePrefixes;   // 底护板码 前缀白名单
+
+        public Product(String id, String[] productPrefixes, String[] coldPlatePrefixes, String[] bottomPlatePrefixes) {
+            this.id = id;
+            this.productPrefixes = productPrefixes;
+            this.coldPlatePrefixes = coldPlatePrefixes;
+            this.bottomPlatePrefixes = bottomPlatePrefixes;
+        }
+
+        public String getId() { return id; }
+        public String[] getProductPrefixes() { return productPrefixes; }
+        public String[] getColdPlatePrefixes() { return coldPlatePrefixes; }
+        public String[] getBottomPlatePrefixes() { return bottomPlatePrefixes; }
+
+        /** 供UI下拉显示 */
+        public String getDisplay() { return id; }
+    }
+
+    // 内置产线位置列表(顺序即 UI 展示顺序)
+    private static final List<Line> LINES = Collections.unmodifiableList(Arrays.asList(
+            new Line("P1"),
+            new Line("P2"),
+            new Line("P3"),
+            new Line("P4")
+    ));
+
+    // 内置产品号列表(顺序即 UI 展示顺序)
+    private static final List<Product> PRODUCTS = Collections.unmodifiableList(Arrays.asList(
+            new Product("P02-1",   new String[]{"+KA99", "+KC14", "+KB98"},  new String[]{"+KB004", "2510"},  new String[]{"+KB77"}),
+            new Product("AY7-610", new String[]{"+KB24"},                     new String[]{"+KA94"},           new String[]{"+KB77"}),
+            new Product("AY7-520", new String[]{"+KB23"},                     new String[]{"+KB004"},          new String[]{"+KB77"}),
+            new Product("P04-1",   new String[]{"+KA93"},                     new String[]{"+KA94"},           new String[]{"+KB77"}),
+            new Product("650",     new String[]{"+KA64IS"},                   new String[]{"+KA64IG"},         new String[]{"+KB78"})
+    ));
+
+    // 默认值
+    private static final String DEFAULT_LINE_ID = "P2";
+    private static final String DEFAULT_PRODUCT_ID = "AY7-610";
+    private static final String DEFAULT_DEVICE_IP = "192.168.0.6";
+
+    // 旧配置迁移:老版本 currentProjectId(P1~P4) → 产品号 的对应关系
+    private static final Map<String, String> LEGACY_PROJECT_TO_PRODUCT;
+    static {
+        Map<String, String> m = new LinkedHashMap<>();
+        m.put("P1", "P02-1");
+        m.put("P2", "AY7-610");
+        m.put("P3", "AY7-520");
+        m.put("P4", "P04-1");
+        LEGACY_PROJECT_TO_PRODUCT = Collections.unmodifiableMap(m);
+    }
+
+    // ========== 单例 ==========
+    private static volatile ProjectConfigManager instance;
+
+    public static ProjectConfigManager getInstance() {
+        if (instance == null) {
+            synchronized (ProjectConfigManager.class) {
+                if (instance == null) {
+                    instance = new ProjectConfigManager();
+                }
+            }
+        }
+        return instance;
+    }
+
+    // ========== 状态 ==========
+    private final Path configPath;
+    private String currentLineId;
+    private String currentProductId;
+    private String deviceIp;
+    private String stationCodeOverride;   // 覆盖 yaml 的 stations[0].code
+    private String lineSnOverride;        // 覆盖 yaml 的 line_sn
+    private String serverIpOverride;      // 覆盖 yaml 的 server.ip
+    private final Map<String, Boolean> featuresOverride = new LinkedHashMap<>();
+    // 设备连接覆盖(null=不覆盖,走yaml默认)
+    private Boolean deviceEnabledOverride;
+    // 设备信息行覆盖:是否启用 + label(寄存器地址不在UI改)
+    private Boolean deviceRow1EnabledOverride;
+    private String  deviceRow1LabelOverride;
+    private Boolean deviceRow2EnabledOverride;
+    private String  deviceRow2LabelOverride;
+    // 物料码输入框显隐覆盖
+    private Boolean showMaterialInputOverride;
+    // 拉铆过程参数上传类型 (A/B/C/D),默认 A
+    private String prodParamsType = "A";
+    // 前缀白名单运行时覆盖(优先级高于产品定义)
+    private String[] productPrefixOverride;
+    private String[] coldPlatePrefixOverride;
+    private String[] bottomPlatePrefixOverride;
+    private final List<Listener> listeners = new ArrayList<>();
+
+    private ProjectConfigManager() {
+        this.configPath = Paths.get(CONFIG_DIR, CONFIG_FILE);
+        this.currentLineId = DEFAULT_LINE_ID;
+        this.currentProductId = DEFAULT_PRODUCT_ID;
+        this.deviceIp = DEFAULT_DEVICE_IP;
+        load();
+    }
+
+    // ========== 加载/保存 ==========
+
+    private void load() {
+        if (!Files.exists(configPath)) {
+            log.info("[ProjectConfig] 配置文件不存在,使用默认: line={}, product={}, ip={}", currentLineId, currentProductId, deviceIp);
+            return;
+        }
+        try {
+            String content = new String(Files.readAllBytes(configPath), StandardCharsets.UTF_8);
+            JSONObject json = JSON.parseObject(content);
+            if (json == null) return;
+
+            // 新字段:产线位置 + 产品号
+            String lineId = json.getString("currentLineId");
+            if (lineId != null && findLine(lineId) != null) {
+                currentLineId = lineId;
+            }
+            String productId = json.getString("currentProductId");
+            if (productId != null && findProduct(productId) != null) {
+                currentProductId = productId;
+            }
+            // 兼容旧配置:老版本只有 currentProjectId(如"P4",旧版项目id同时代表产线位置+产品号)
+            // 若新字段缺失,则按旧的 id→产品号 对应关系拆分迁移
+            if (lineId == null || productId == null) {
+                String oldPid = json.getString("currentProjectId");
+                if (oldPid != null) {
+                    if (lineId == null && findLine(oldPid) != null) {
+                        currentLineId = oldPid;
+                    }
+                    if (productId == null) {
+                        String legacyProduct = LEGACY_PROJECT_TO_PRODUCT.get(oldPid.toUpperCase());
+                        if (legacyProduct != null) {
+                            currentProductId = legacyProduct;
+                        }
+                    }
+                }
+            }
+            String ip = json.getString("deviceIp");
+            if (ip != null && !ip.trim().isEmpty()) {
+                deviceIp = ip.trim();
+            }
+            // 加载功能开关覆盖
+            JSONObject featuresObj = json.getJSONObject("featuresOverride");
+            if (featuresObj != null) {
+                featuresOverride.clear();
+                for (String key : featuresObj.keySet()) {
+                    Boolean v = featuresObj.getBoolean(key);
+                    if (v != null) featuresOverride.put(key, v);
+                }
+            }
+            String sc = json.getString("stationCodeOverride");
+            if (sc != null && !sc.trim().isEmpty()) {
+                stationCodeOverride = sc.trim();
+            }
+            String ls = json.getString("lineSnOverride");
+            if (ls != null && !ls.trim().isEmpty()) {
+                lineSnOverride = ls.trim();
+            }
+            String sIp = json.getString("serverIpOverride");
+            if (sIp != null && !sIp.trim().isEmpty()) {
+                serverIpOverride = sIp.trim();
+            }
+            // 设备连接覆盖
+            if (json.containsKey("deviceEnabledOverride")) {
+                deviceEnabledOverride = json.getBoolean("deviceEnabledOverride");
+            }
+            // 设备信息行
+            JSONObject uiObj = json.getJSONObject("uiOverride");
+            if (uiObj != null) {
+                deviceRow1EnabledOverride = uiObj.getBoolean("deviceRow1Enabled");
+                deviceRow1LabelOverride = trimToNull(uiObj.getString("deviceRow1Label"));
+                deviceRow2EnabledOverride = uiObj.getBoolean("deviceRow2Enabled");
+                deviceRow2LabelOverride = trimToNull(uiObj.getString("deviceRow2Label"));
+                showMaterialInputOverride = uiObj.getBoolean("showMaterialInput");
+                String pt = trimToNull(uiObj.getString("prodParamsType"));
+                if (pt != null) prodParamsType = pt.toUpperCase();
+            }
+            // 前缀白名单覆盖
+            JSONObject prefixObj = json.getJSONObject("prefixOverride");
+            if (prefixObj != null) {
+                String productPrefixStr = trimToNull(prefixObj.getString("product"));
+                if (productPrefixStr != null) {
+                    productPrefixOverride = productPrefixStr.split(",");
+                    for (int i = 0; i < productPrefixOverride.length; i++) {
+                        productPrefixOverride[i] = productPrefixOverride[i].trim();
+                    }
+                }
+                String coldPlatePrefixStr = trimToNull(prefixObj.getString("coldPlate"));
+                if (coldPlatePrefixStr != null) {
+                    coldPlatePrefixOverride = coldPlatePrefixStr.split(",");
+                    for (int i = 0; i < coldPlatePrefixOverride.length; i++) {
+                        coldPlatePrefixOverride[i] = coldPlatePrefixOverride[i].trim();
+                    }
+                }
+                String bottomPlatePrefixStr = trimToNull(prefixObj.getString("bottomPlate"));
+                if (bottomPlatePrefixStr != null) {
+                    bottomPlatePrefixOverride = bottomPlatePrefixStr.split(",");
+                    for (int i = 0; i < bottomPlatePrefixOverride.length; i++) {
+                        bottomPlatePrefixOverride[i] = bottomPlatePrefixOverride[i].trim();
+                    }
+                }
+            }
+            log.info("[ProjectConfig] 加载成功: line={}, product={}, ip={}, station={}, lineSn={}, features={}, prefixOverride={}",
+                    currentLineId, currentProductId, deviceIp, stationCodeOverride, lineSnOverride, featuresOverride,
+                    (productPrefixOverride != null || coldPlatePrefixOverride != null || bottomPlatePrefixOverride != null));
+        } catch (Exception e) {
+            log.error("[ProjectConfig] 加载失败: {}", e.getMessage(), e);
+        }
+    }
+
+    private void save() {
+        try {
+            if (configPath.getParent() != null) {
+                Files.createDirectories(configPath.getParent());
+            }
+            JSONObject root = new JSONObject();
+            root.put("currentLineId", currentLineId);
+            root.put("currentProductId", currentProductId);
+            root.put("deviceIp", deviceIp);
+            if (stationCodeOverride != null) root.put("stationCodeOverride", stationCodeOverride);
+            if (lineSnOverride != null) root.put("lineSnOverride", lineSnOverride);
+            if (serverIpOverride != null) root.put("serverIpOverride", serverIpOverride);
+            if (deviceEnabledOverride != null) root.put("deviceEnabledOverride", deviceEnabledOverride);
+            JSONObject uiObj = new JSONObject();
+            if (deviceRow1EnabledOverride != null) uiObj.put("deviceRow1Enabled", deviceRow1EnabledOverride);
+            if (deviceRow1LabelOverride != null)   uiObj.put("deviceRow1Label", deviceRow1LabelOverride);
+            if (deviceRow2EnabledOverride != null) uiObj.put("deviceRow2Enabled", deviceRow2EnabledOverride);
+            if (deviceRow2LabelOverride != null)   uiObj.put("deviceRow2Label", deviceRow2LabelOverride);
+            if (showMaterialInputOverride != null) uiObj.put("showMaterialInput", showMaterialInputOverride);
+            uiObj.put("prodParamsType", prodParamsType);
+            if (!uiObj.isEmpty()) root.put("uiOverride", uiObj);
+            if (!featuresOverride.isEmpty()) {
+                root.put("featuresOverride", featuresOverride);
+            }
+            // 前缀白名单覆盖
+            if (productPrefixOverride != null || coldPlatePrefixOverride != null || bottomPlatePrefixOverride != null) {
+                JSONObject prefixObj = new JSONObject();
+                if (productPrefixOverride != null) {
+                    prefixObj.put("product", String.join(",", productPrefixOverride));
+                }
+                if (coldPlatePrefixOverride != null) {
+                    prefixObj.put("coldPlate", String.join(",", coldPlatePrefixOverride));
+                }
+                if (bottomPlatePrefixOverride != null) {
+                    prefixObj.put("bottomPlate", String.join(",", bottomPlatePrefixOverride));
+                }
+                root.put("prefixOverride", prefixObj);
+            }
+            root.put("lastModified", System.currentTimeMillis());
+            String content = JSON.toJSONString(root, JSONWriter.Feature.PrettyFormat);
+            Files.write(configPath, content.getBytes(StandardCharsets.UTF_8));
+            log.info("[ProjectConfig] 保存成功: {}", configPath);
+        } catch (IOException e) {
+            log.error("[ProjectConfig] 保存失败: {}", e.getMessage(), e);
+        }
+    }
+
+    // ========== 产线位置查询 ==========
+
+    public List<Line> getLines() {
+        return LINES;
+    }
+
+    public Line findLine(String id) {
+        if (id == null) return null;
+        for (Line l : LINES) {
+            if (l.getId().equalsIgnoreCase(id)) return l;
+        }
+        return null;
+    }
+
+    public Line getCurrentLine() {
+        Line l = findLine(currentLineId);
+        return l != null ? l : LINES.get(0);
+    }
+
+    public String getCurrentLineId() {
+        return currentLineId;
+    }
+
+    // ========== 产品号查询 ==========
+
+    public List<Product> getProducts() {
+        return PRODUCTS;
+    }
+
+    public Product findProduct(String id) {
+        if (id == null) return null;
+        for (Product p : PRODUCTS) {
+            if (p.getId().equalsIgnoreCase(id)) return p;
+        }
+        return null;
+    }
+
+    public Product getCurrentProduct() {
+        Product p = findProduct(currentProductId);
+        return p != null ? p : PRODUCTS.get(0);
+    }
+
+    public String getCurrentProductId() {
+        return currentProductId;
+    }
+
+    // ========== 前缀快捷方法 ==========
+
+    /** 工件码(框架码)前缀数组,优先返回运行时覆盖值,其次返回产品定义 */
+    public String[] getProductPrefixes() {
+        if (productPrefixOverride != null) return productPrefixOverride;
+        Product p = getCurrentProduct();
+        return p != null ? p.getProductPrefixes() : null;
+    }
+
+    /** 冷板码前缀数组,优先返回运行时覆盖值 */
+    public String[] getColdPlatePrefixes() {
+        if (coldPlatePrefixOverride != null) return coldPlatePrefixOverride;
+        Product p = getCurrentProduct();
+        return p != null ? p.getColdPlatePrefixes() : null;
+    }
+
+    /** 底护板码前缀数组,优先返回运行时覆盖值 */
+    public String[] getBottomPlatePrefixes() {
+        if (bottomPlatePrefixOverride != null) return bottomPlatePrefixOverride;
+        Product p = getCurrentProduct();
+        return p != null ? p.getBottomPlatePrefixes() : null;
+    }
+
+    /**
+     * 校验工件码是否匹配任一允许的前缀
+     */
+    public boolean matchProductPrefix(String barcode) {
+        if (barcode == null || barcode.isEmpty()) return false;
+        String[] prefixes = getProductPrefixes();
+        if (prefixes == null || prefixes.length == 0) return true;
+        for (String prefix : prefixes) {
+            if (prefix != null && barcode.startsWith(prefix.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 拼接所有允许的工件码前缀,用于错误提示
+     */
+    public String getProductPrefixHint() {
+        String[] prefixes = getProductPrefixes();
+        if (prefixes == null || prefixes.length == 0) return "";
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < prefixes.length; i++) {
+            if (i > 0) sb.append("/");
+            sb.append(prefixes[i].trim());
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 校验冷板码是否匹配任一允许的前缀
+     */
+    public boolean matchColdPlatePrefix(String barcode) {
+        if (barcode == null || barcode.isEmpty()) return false;
+        String[] prefixes = getColdPlatePrefixes();
+        if (prefixes == null || prefixes.length == 0) return true;
+        for (String prefix : prefixes) {
+            if (prefix != null && barcode.startsWith(prefix.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 拼接所有允许的冷板码前缀,用于错误提示
+     */
+    public String getColdPlatePrefixHint() {
+        String[] prefixes = getColdPlatePrefixes();
+        if (prefixes == null || prefixes.length == 0) return "";
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < prefixes.length; i++) {
+            if (i > 0) sb.append("/");
+            sb.append(prefixes[i].trim());
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 校验底护板码是否匹配任一允许的前缀
+     */
+    public boolean matchBottomPlatePrefix(String barcode) {
+        if (barcode == null || barcode.isEmpty()) return false;
+        String[] prefixes = getBottomPlatePrefixes();
+        if (prefixes == null || prefixes.length == 0) return true;
+        for (String prefix : prefixes) {
+            if (prefix != null && barcode.startsWith(prefix.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 拼接所有允许的底护板码前缀,用于错误提示
+     */
+    public String getBottomPlatePrefixHint() {
+        String[] prefixes = getBottomPlatePrefixes();
+        if (prefixes == null || prefixes.length == 0) return "";
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < prefixes.length; i++) {
+            if (i > 0) sb.append("/");
+            sb.append(prefixes[i].trim());
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 根据物料标签获取前缀(供 ScanMaterialStep 区分)
+     * 支持"冷板码""底护板码"两种标签
+     * @deprecated 使用 matchMaterialPrefix 和 getMaterialPrefixHint 替代
+     */
+    @Deprecated
+    public String getMaterialPrefixByLabel(String label) {
+        if (label == null) return null;
+        String[] prefixes = null;
+        if (label.contains("冷板")) prefixes = getColdPlatePrefixes();
+        else if (label.contains("底护板")) prefixes = getBottomPlatePrefixes();
+        // 返回第一个前缀(兼容旧逻辑)
+        return (prefixes != null && prefixes.length > 0) ? prefixes[0] : null;
+    }
+
+    /**
+     * 根据物料标签校验前缀(白名单匹配)
+     */
+    public boolean matchMaterialPrefix(String barcode, String label) {
+        if (label == null) return true;
+        if (label.contains("冷板")) return matchColdPlatePrefix(barcode);
+        if (label.contains("底护板")) return matchBottomPlatePrefix(barcode);
+        return true;
+    }
+
+    /**
+     * 根据物料标签获取前缀提示
+     */
+    public String getMaterialPrefixHint(String label) {
+        if (label == null) return "";
+        if (label.contains("冷板")) return getColdPlatePrefixHint();
+        if (label.contains("底护板")) return getBottomPlatePrefixHint();
+        return "";
+    }
+
+    // ========== 切换产线位置 ==========
+
+    public void switchLine(String lineId) {
+        Line l = findLine(lineId);
+        if (l == null) {
+            log.warn("[ProjectConfig] 无效的产线位置id: {}", lineId);
+            return;
+        }
+        if (l.getId().equalsIgnoreCase(currentLineId)) return;
+        String old = currentLineId;
+        currentLineId = l.getId();
+        save();
+        log.info("[ProjectConfig] 切换产线位置: {} -> {}", old, currentLineId);
+        fireLineChanged(l);
+    }
+
+    // ========== 切换产品号 ==========
+
+    public void switchProduct(String productId) {
+        Product p = findProduct(productId);
+        if (p == null) {
+            log.warn("[ProjectConfig] 无效的产品号id: {}", productId);
+            return;
+        }
+        if (p.getId().equalsIgnoreCase(currentProductId)) return;
+        String old = currentProductId;
+        currentProductId = p.getId();
+        save();
+        log.info("[ProjectConfig] 切换产品号: {} -> {}", old, currentProductId);
+        fireProductChanged(p);
+    }
+
+    // ========== 设备IP ==========
+
+    public String getDeviceIp() {
+        return deviceIp;
+    }
+
+    public void setDeviceIp(String ip) {
+        if (ip == null || ip.trim().isEmpty()) return;
+        ip = ip.trim();
+        if (ip.equals(this.deviceIp)) return;
+        String old = this.deviceIp;
+        this.deviceIp = ip;
+        save();
+        log.info("[ProjectConfig] 修改拉铆设备IP: {} -> {}", old, ip);
+        fireDeviceIpChanged(ip);
+    }
+
+    // ========== 功能开关覆盖 ==========
+
+    /**
+     * 获取功能开关覆盖值,null 表示未覆盖(走 yaml 默认)
+     */
+    public Boolean getFeatureOverride(String feature) {
+        return featuresOverride.get(feature);
+    }
+
+    public Map<String, Boolean> getFeaturesOverride() {
+        return new LinkedHashMap<>(featuresOverride);
+    }
+
+    /**
+     * 批量设置功能开关覆盖(UI一次提交多项时用)
+     * newValues 为 null 或空则清空覆盖(恢复 yaml 默认)
+     */
+    public void setFeaturesOverride(Map<String, Boolean> newValues) {
+        featuresOverride.clear();
+        if (newValues != null) {
+            for (Map.Entry<String, Boolean> e : newValues.entrySet()) {
+                if (e.getKey() != null && e.getValue() != null) {
+                    featuresOverride.put(e.getKey(), e.getValue());
+                }
+            }
+        }
+        save();
+        log.info("[ProjectConfig] 更新功能开关: {}", featuresOverride);
+        fireFeaturesChanged();
+    }
+
+    // ========== 工位号/线体覆盖 ==========
+
+    public String getStationCodeOverride() { return stationCodeOverride; }
+    public String getLineSnOverride() { return lineSnOverride; }
+    public String getServerIpOverride() { return serverIpOverride; }
+
+    // ========== 前缀白名单覆盖 ==========
+
+    /**
+     * 设置工件码前缀白名单覆盖
+     * @param prefixes 逗号分隔的前缀字符串,如 "+KA99,+KC14,+KB98";null 或空字符串表示清除覆盖
+     */
+    public void setProductPrefixOverride(String prefixes) {
+        if (prefixes == null || prefixes.trim().isEmpty()) {
+            productPrefixOverride = null;
+        } else {
+            productPrefixOverride = prefixes.split(",");
+            for (int i = 0; i < productPrefixOverride.length; i++) {
+                productPrefixOverride[i] = productPrefixOverride[i].trim();
+            }
+        }
+        save();
+        log.info("[ProjectConfig] 工件码前缀覆盖: {}", prefixes);
+        firePrefixChanged();
+    }
+
+    /**
+     * 设置冷板码前缀白名单覆盖
+     */
+    public void setColdPlatePrefixOverride(String prefixes) {
+        if (prefixes == null || prefixes.trim().isEmpty()) {
+            coldPlatePrefixOverride = null;
+        } else {
+            coldPlatePrefixOverride = prefixes.split(",");
+            for (int i = 0; i < coldPlatePrefixOverride.length; i++) {
+                coldPlatePrefixOverride[i] = coldPlatePrefixOverride[i].trim();
+            }
+        }
+        save();
+        log.info("[ProjectConfig] 冷板码前缀覆盖: {}", prefixes);
+        firePrefixChanged();
+    }
+
+    /**
+     * 设置底护板码前缀白名单覆盖
+     */
+    public void setBottomPlatePrefixOverride(String prefixes) {
+        if (prefixes == null || prefixes.trim().isEmpty()) {
+            bottomPlatePrefixOverride = null;
+        } else {
+            bottomPlatePrefixOverride = prefixes.split(",");
+            for (int i = 0; i < bottomPlatePrefixOverride.length; i++) {
+                bottomPlatePrefixOverride[i] = bottomPlatePrefixOverride[i].trim();
+            }
+        }
+        save();
+        log.info("[ProjectConfig] 底护板码前缀覆盖: {}", prefixes);
+        firePrefixChanged();
+    }
+
+    /**
+     * 获取工件码前缀覆盖的字符串表示(用于UI显示)
+     */
+    public String getProductPrefixOverrideString() {
+        if (productPrefixOverride == null) return null;
+        return String.join(",", productPrefixOverride);
+    }
+
+    /**
+     * 获取冷板码前缀覆盖的字符串表示
+     */
+    public String getColdPlatePrefixOverrideString() {
+        if (coldPlatePrefixOverride == null) return null;
+        return String.join(",", coldPlatePrefixOverride);
+    }
+
+    /**
+     * 获取底护板码前缀覆盖的字符串表示
+     */
+    public String getBottomPlatePrefixOverrideString() {
+        if (bottomPlatePrefixOverride == null) return null;
+        return String.join(",", bottomPlatePrefixOverride);
+    }
+
+    /**
+     * 清除所有前缀覆盖,恢复使用产品定义
+     */
+    public void clearPrefixOverrides() {
+        productPrefixOverride = null;
+        coldPlatePrefixOverride = null;
+        bottomPlatePrefixOverride = null;
+        save();
+        log.info("[ProjectConfig] 已清除所有前缀覆盖");
+        firePrefixChanged();
+    }
+
+    /**
+     * 设置服务器(MES)IP 覆盖
+     */
+    public void setServerIpOverride(String ip) {
+        if (ip == null) return;
+        ip = ip.trim();
+        if (ip.isEmpty()) return;
+        if (ip.equalsIgnoreCase(this.serverIpOverride)) return;
+        this.serverIpOverride = ip;
+        save();
+        log.info("[ProjectConfig] 修改服务器IP: {}", ip);
+        fireServerIpChanged(ip);
+    }
+
+    public Boolean getDeviceRow1EnabledOverride() { return deviceRow1EnabledOverride; }
+
+    /**
+     * 拉铆过程参数上传类型 (A/B/C/D),默认 A
+     */
+    public String getProdParamsType() {
+        return prodParamsType != null ? prodParamsType : "A";
+    }
+
+    /**
+     * 设置拉铆过程参数上传类型
+     * @param type 有效值 A/B/C/D(大小写不敏感,其它值忽略)
+     */
+    public void setProdParamsType(String type) {
+        if (type == null) return;
+        String t = type.trim().toUpperCase();
+        if (t.isEmpty()) return;
+        if (!"A".equals(t) && !"B".equals(t) && !"C".equals(t) && !"D".equals(t)) {
+            log.warn("[ProjectConfig] 无效的拉铆参数类型: {}, 忽略", type);
+            return;
+        }
+        if (t.equals(this.prodParamsType)) return;
+        String old = this.prodParamsType;
+        this.prodParamsType = t;
+        save();
+        log.info("[ProjectConfig] 修改拉铆参数类型: {} -> {}", old, t);
+    }
+
+    // ========== 设备连接覆盖 ==========
+
+    public Boolean getDeviceEnabledOverride() { return deviceEnabledOverride; }
+
+    public void setDeviceEnabledOverride(Boolean enabled) {
+        this.deviceEnabledOverride = enabled;
+        save();
+        log.info("[ProjectConfig] 设备连接覆盖: {}", enabled);
+    }
+    public String  getDeviceRow1LabelOverride()   { return deviceRow1LabelOverride; }
+    public Boolean getDeviceRow2EnabledOverride() { return deviceRow2EnabledOverride; }
+    public String  getDeviceRow2LabelOverride()   { return deviceRow2LabelOverride; }
+    public Boolean getShowMaterialInputOverride() { return showMaterialInputOverride; }
+
+    /**
+     * 更新设备信息行UI覆盖(启用/label),null 表示不覆盖
+     */
+    public void setDeviceInfoRowsOverride(Boolean row1Enabled, String row1Label,
+                                          Boolean row2Enabled, String row2Label,
+                                          Boolean showMaterialInput) {
+        this.deviceRow1EnabledOverride = row1Enabled;
+        this.deviceRow1LabelOverride = trimToNull(row1Label);
+        this.deviceRow2EnabledOverride = row2Enabled;
+        this.deviceRow2LabelOverride = trimToNull(row2Label);
+        this.showMaterialInputOverride = showMaterialInput;
+        save();
+        log.info("[ProjectConfig] UI覆盖: row1=({},{}), row2=({},{}), showMaterial={}",
+                row1Enabled, deviceRow1LabelOverride,
+                row2Enabled, deviceRow2LabelOverride,
+                showMaterialInput);
+        fireUiChanged();
+    }
+
+    private static String trimToNull(String s) {
+        if (s == null) return null;
+        String t = s.trim();
+        return t.isEmpty() ? null : t;
+    }
+
+    /**
+     * 设置工位号与线体覆盖(任一可为null表示不覆盖该项)
+     */
+    public void setStationOverride(String stationCode, String lineSn) {
+        boolean changed = false;
+        if (stationCode != null) {
+            stationCode = stationCode.trim();
+            if (!stationCode.isEmpty() && !stationCode.equalsIgnoreCase(this.stationCodeOverride)) {
+                this.stationCodeOverride = stationCode;
+                changed = true;
+            }
+        }
+        if (lineSn != null) {
+            lineSn = lineSn.trim();
+            if (!lineSn.isEmpty() && !lineSn.equalsIgnoreCase(this.lineSnOverride)) {
+                this.lineSnOverride = lineSn;
+                changed = true;
+            }
+        }
+        if (changed) {
+            save();
+            log.info("[ProjectConfig] 修改工位: code={}, lineSn={}", stationCodeOverride, lineSnOverride);
+            fireStationChanged();
+        }
+    }
+
+    // ========== 事件监听(UI订阅) ==========
+
+    public interface Listener {
+        default void onLineChanged(Line line) {}
+        default void onProductChanged(Product product) {}
+        default void onDeviceIpChanged(String newIp) {}
+        default void onServerIpChanged(String newIp) {}
+        default void onFeaturesChanged() {}
+        default void onStationChanged() {}
+        default void onUiChanged() {}
+        default void onPrefixChanged() {}
+    }
+
+    public void addListener(Listener l) {
+        if (l != null && !listeners.contains(l)) listeners.add(l);
+    }
+
+    public void removeListener(Listener l) {
+        listeners.remove(l);
+    }
+
+    private void fireLineChanged(Line l) {
+        for (Listener listener : new ArrayList<>(listeners)) {
+            try { listener.onLineChanged(l); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
+
+    private void fireProductChanged(Product p) {
+        for (Listener listener : new ArrayList<>(listeners)) {
+            try { listener.onProductChanged(p); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
+
+    private void fireDeviceIpChanged(String ip) {
+        for (Listener l : new ArrayList<>(listeners)) {
+            try { l.onDeviceIpChanged(ip); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
+
+    private void fireServerIpChanged(String ip) {
+        for (Listener l : new ArrayList<>(listeners)) {
+            try { l.onServerIpChanged(ip); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
+
+    private void fireFeaturesChanged() {
+        for (Listener l : new ArrayList<>(listeners)) {
+            try { l.onFeaturesChanged(); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
+
+    private void fireStationChanged() {
+        for (Listener l : new ArrayList<>(listeners)) {
+            try { l.onStationChanged(); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
+
+    private void fireUiChanged() {
+        for (Listener l : new ArrayList<>(listeners)) {
+            try { l.onUiChanged(); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
+
+    private void firePrefixChanged() {
+        for (Listener l : new ArrayList<>(listeners)) {
+            try { l.onPrefixChanged(); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
+}

+ 984 - 0
src/com/mes/core/StationConfig.java

@@ -0,0 +1,984 @@
+package com.mes.core;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 工位配置类 - 从YAML配置文件加载工位配置
+ * 解决工位号硬编码问题,支持单工位/双工位动态切换
+ */
+public class StationConfig {
+    private static final Logger log = LoggerFactory.getLogger(StationConfig.class);
+
+    // 单例实例
+    private static StationConfig instance;
+
+    // ========== 工位配置 ==========
+    private String mode;                    // single | dual
+    private String stationName;             // 工位/工序名称(如"液冷板安装+激光点固")
+    private List<StationInfo> stations;     // 工位列表
+    private String lineSn;                  // 线体号
+
+    // ========== 服务器配置 ==========
+    private String serverIp;
+    private int tcpPort;
+    private int httpPort;                // HTTP服务端口
+    private int heartBeatCycle;
+
+    // ========== 流程配置 ==========
+    private List<StepConfig> workflowSteps;
+    private String submitMode;               // manual | auto (手动点击OK/NG | 自动提交)
+    private Map<String, Boolean> yamlFeatures = new HashMap<>();  // yaml 中定义的功能默认开关
+
+    // ========== 设备配置 ==========
+    private boolean deviceEnabled;
+    private String deviceType;              // modbus_tcp | modbus_rtu | s7 | none
+    private List<DeviceConnection> deviceConnections;
+    private boolean timeoutEnabled;
+    private int connectTimeout = 3000;      // Modbus连接超时(ms)
+    private int heartbeatAddress;
+    private int readySignalAddress;
+    
+    // 设备地址配置
+    private DeviceAddresses deviceAddresses;
+    // 完成条件配置
+    private CompleteCondition completeCondition;
+    // 心跳配置
+    private HeartbeatConfig heartbeatConfig;
+    // 继电器配置
+    private RelayConfig relayConfig;
+    // 过程参数采集配置
+    private ProdParamsConfig prodParamsConfig;
+
+    // ========== UI配置 ==========
+    private boolean showDeviceInfo;
+    private boolean showDeviceInfo2;        // 是否显示第二行设备信息
+    private boolean showMaterialInput;
+    private String materialLabel;
+    private List<DeviceInfoRow> deviceInfoRows;  // 设备信息行配置
+
+    // 原始配置Map,用于获取未解析的配置
+    private Map<String, Object> rawConfig;
+
+    private StationConfig() {
+        stations = new ArrayList<>();
+        workflowSteps = new ArrayList<>();
+        deviceConnections = new ArrayList<>();
+        deviceInfoRows = new ArrayList<>();
+    }
+
+    /**
+     * 获取单例实例
+     */
+    public static synchronized StationConfig getInstance() {
+        if (instance == null) {
+            instance = new StationConfig();
+            instance.loadFromResource("resources/config/station.yaml");
+        }
+        return instance;
+    }
+
+    /**
+     * 重新加载配置(用于热更新)
+     */
+    public static synchronized void reload() {
+        instance = new StationConfig();
+        instance.loadFromResource("resources/config/station.yaml");
+    }
+
+    /**
+     * 从指定路径加载配置
+     */
+    public static synchronized StationConfig loadFrom(String resourcePath) {
+        instance = new StationConfig();
+        instance.loadFromResource(resourcePath);
+        return instance;
+    }
+
+    /**
+     * 从资源文件加载配置
+     */
+    private void loadFromResource(String resourcePath) {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourcePath)) {
+            if (is == null) {
+                log.error("配置文件不存在: {}", resourcePath);
+                throw new RuntimeException("配置文件不存在: " + resourcePath);
+            }
+
+            Yaml yaml = new Yaml();
+            Map<String, Object> config = yaml.load(new InputStreamReader(is, StandardCharsets.UTF_8));
+            parseConfig(config);
+            log.info("配置加载成功: mode={}, stations={}", mode, stations.size());
+
+        } catch (Exception e) {
+            log.error("加载配置文件失败: {}", e.getMessage(), e);
+            throw new RuntimeException("加载配置文件失败", e);
+        }
+    }
+
+    /**
+     * 展开 steps 列表:遇到 feature 块时按功能开关(含 override)过滤
+     * feature 块形如:
+     *   - feature: cold_plate
+     *     steps: [...]
+     */
+    @SuppressWarnings("unchecked")
+    private void expandSteps(List<Map<String, Object>> stepsArr, List<StepConfig> out) {
+        for (Map<String, Object> node : stepsArr) {
+            Object featureKey = node.get("feature");
+            if (featureKey != null) {
+                String fname = featureKey.toString();
+                if (!isFeatureEnabled(fname)) {
+                    log.debug("跳过禁用的feature: {}", fname);
+                    continue;
+                }
+                List<Map<String, Object>> inner = (List<Map<String, Object>>) node.get("steps");
+                if (inner != null) {
+                    expandSteps(inner, out);
+                }
+                continue;
+            }
+            // 普通步骤
+            if (node.get("id") == null) continue;
+            StepConfig stepConfig = new StepConfig();
+            stepConfig.setId((String) node.get("id"));
+            stepConfig.setName((String) node.get("name"));
+            stepConfig.setCraft((String) node.get("craft"));
+            Map<String, Object> stepConfigMap = (Map<String, Object>) node.get("config");
+            if (stepConfigMap != null) {
+                stepConfig.setConfig(stepConfigMap);
+            }
+            out.add(stepConfig);
+        }
+    }
+
+    /**
+     * 查询功能是否启用
+     * 优先级:运行时 override (ProjectConfigManager) > yaml 默认 > false
+     */
+    public boolean isFeatureEnabled(String feature) {
+        Boolean override = ProjectConfigManager.getInstance().getFeatureOverride(feature);
+        if (override != null) return override;
+        Boolean def = yamlFeatures.get(feature);
+        return def != null && def;
+    }
+
+    /**
+     * 获取所有 yaml 定义的功能开关(用于 UI 显示可用功能列表)
+     */
+    public Map<String, Boolean> getYamlFeatures() {
+        return new HashMap<>(yamlFeatures);
+    }
+
+    /**
+     * 根据当前功能开关推导物料码标签:
+     * 启用 cold_plate → "冷板码"
+     * 启用 bottom_plate → "底护板码"
+     * 都未启用 → yaml 配置的 materialLabel
+     */
+    public String getEffectiveMaterialLabel() {
+        if (isFeatureEnabled("cold_plate")) return "冷板码";
+        if (isFeatureEnabled("bottom_plate")) return "底护板码";
+        return materialLabel;
+    }
+
+    /**
+     * 解析YAML配置
+     */
+    @SuppressWarnings("unchecked")
+    private void parseConfig(Map<String, Object> config) {
+        rawConfig = config;
+
+        // 解析工位配置
+        Map<String, Object> stationObj = (Map<String, Object>) config.get("station");
+        if (stationObj != null) {
+            mode = (String) stationObj.get("mode");
+            stationName = (String) stationObj.get("name");
+            lineSn = (String) stationObj.get("line_sn");
+
+            List<Map<String, Object>> stationsArr = (List<Map<String, Object>>) stationObj.get("stations");
+            if (stationsArr != null) {
+                for (int i = 0; i < stationsArr.size(); i++) {
+                    Map<String, Object> s = stationsArr.get(i);
+                    stations.add(new StationInfo(
+                            i,
+                            (String) s.get("code")
+                    ));
+                }
+            }
+        }
+
+        // 解析服务器配置
+        Map<String, Object> serverObj = (Map<String, Object>) config.get("server");
+        if (serverObj != null) {
+            serverIp = (String) serverObj.get("ip");
+            tcpPort = getIntValue(serverObj, "tcp_port", 3000);
+            httpPort = getIntValue(serverObj, "http_port", 8980);
+            heartBeatCycle = getIntValue(serverObj, "heart_beat_cycle", 60);
+        }
+
+        // 解析流程配置
+        Map<String, Object> workflowObj = (Map<String, Object>) config.get("workflow");
+        if (workflowObj != null) {
+            // 提交模式:manual(手动点击OK/NG) | auto(设备信号自动提交)
+            submitMode = (String) workflowObj.get("submit_mode");
+            if (submitMode == null || submitMode.isEmpty()) {
+                submitMode = "manual";  // 默认手动模式
+            }
+
+            // 解析功能开关(yaml 默认值)
+            yamlFeatures.clear();
+            Map<String, Object> featuresObj = (Map<String, Object>) workflowObj.get("features");
+            if (featuresObj != null) {
+                for (Map.Entry<String, Object> e : featuresObj.entrySet()) {
+                    if (e.getValue() instanceof Boolean) {
+                        yamlFeatures.put(e.getKey(), (Boolean) e.getValue());
+                    }
+                }
+            }
+
+            List<Map<String, Object>> stepsArr = (List<Map<String, Object>>) workflowObj.get("steps");
+            if (stepsArr != null) {
+                expandSteps(stepsArr, workflowSteps);
+            }
+        }
+
+        // 解析设备配置
+        Map<String, Object> deviceObj = (Map<String, Object>) config.get("device");
+        if (deviceObj != null) {
+            deviceEnabled = getBooleanValue(deviceObj, "enabled", false);
+            deviceType = (String) deviceObj.get("type");
+            timeoutEnabled = getBooleanValue(deviceObj, "timeout_enabled", false);
+            connectTimeout = getIntValue(deviceObj, "connect_timeout", 3000);
+            heartbeatAddress = getIntValue(deviceObj, "heartbeat_address", 4160);
+            readySignalAddress = getIntValue(deviceObj, "ready_signal_address", 8);
+
+            List<Map<String, Object>> connArr = (List<Map<String, Object>>) deviceObj.get("connections");
+            if (connArr != null) {
+                for (int i = 0; i < connArr.size(); i++) {
+                    Map<String, Object> conn = connArr.get(i);
+                    deviceConnections.add(new DeviceConnection(
+                            getIntValue(conn, "station_index", i),
+                            (String) conn.get("ip"),
+                            getIntValue(conn, "port", 502)
+                    ));
+                }
+            }
+            
+            // 解析设备地址配置
+            Map<String, Object> addressesObj = (Map<String, Object>) deviceObj.get("addresses");
+            if (addressesObj != null) {
+                deviceAddresses = new DeviceAddresses();
+                deviceAddresses.setEnableSignal((String) addressesObj.get("enable_signal"));
+                deviceAddresses.setStartSignal((String) addressesObj.get("start_signal"));
+                deviceAddresses.setCompleteSignal((String) addressesObj.get("complete_signal"));
+                deviceAddresses.setDeviceStatus((String) addressesObj.get("device_status"));
+                deviceAddresses.setFinishedCount(getIntValue(addressesObj, "finished_count", 0));
+                deviceAddresses.setPresetCount(getIntValue(addressesObj, "preset_count", 0));
+                deviceAddresses.setQualifiedCount(getIntValue(addressesObj, "qualified_count", 0));
+                deviceAddresses.setStartCommand(getIntValue(addressesObj, "start_command", 0));
+            }
+            
+            // 解析完成条件配置
+            Map<String, Object> conditionObj = (Map<String, Object>) deviceObj.get("complete_condition");
+            if (conditionObj != null) {
+                completeCondition = new CompleteCondition();
+                completeCondition.setType((String) conditionObj.get("type"));
+                completeCondition.setRunningValue(getIntValue(conditionObj, "running_value", 1));
+                completeCondition.setStoppedValue(getIntValue(conditionObj, "stopped_value", 0));
+                completeCondition.setMinRunningTime(getIntValue(conditionObj, "min_running_time", 0));
+            } else {
+                // 默认使用信号判断
+                completeCondition = new CompleteCondition();
+                completeCondition.setType("signal");
+            }
+            
+            // 解析心跳配置
+            Map<String, Object> heartbeatObj = (Map<String, Object>) deviceObj.get("heartbeat");
+            if (heartbeatObj != null) {
+                heartbeatConfig = new HeartbeatConfig();
+                heartbeatConfig.setEnabled(getBooleanValue(heartbeatObj, "enabled", true));
+                heartbeatConfig.setAddress(getIntValue(heartbeatObj, "address", 4160));
+                heartbeatConfig.setInterval(getIntValue(heartbeatObj, "interval", 500));
+                heartbeatConfig.setTimeoutAddress(getIntValue(heartbeatObj, "timeout_address", 4170));
+                heartbeatConfig.setTimeoutValue(getIntValue(heartbeatObj, "timeout_value", 10));
+            }
+            
+            // 解析继电器配置
+            Map<String, Object> relayObj = (Map<String, Object>) deviceObj.get("relay");
+            if (relayObj != null) {
+                relayConfig = new RelayConfig();
+                relayConfig.setEnabled(getBooleanValue(relayObj, "enabled", true));
+                relayConfig.setType((String) relayObj.get("type"));
+                relayConfig.setPort((String) relayObj.get("port"));
+                relayConfig.setBaudrate(getIntValue(relayObj, "baudrate", 38400));
+                relayConfig.setOpenCommand((String) relayObj.get("open_command"));
+                relayConfig.setCloseCommand((String) relayObj.get("close_command"));
+            }
+            
+            // 解析过程参数采集配置
+            Map<String, Object> prodParamsObj = (Map<String, Object>) deviceObj.get("prod_params");
+            if (prodParamsObj != null) {
+                prodParamsConfig = new ProdParamsConfig();
+                prodParamsConfig.setEnabled(getBooleanValue(prodParamsObj, "enabled", false));
+                prodParamsConfig.setFoutAddress(getIntValue(prodParamsObj, "fout_address", 4112));
+                prodParamsConfig.setSoutAddress(getIntValue(prodParamsObj, "sout_address", 4120));
+                prodParamsConfig.setFminAddress(getIntValue(prodParamsObj, "fmin_address", 4116));
+                prodParamsConfig.setSminAddress(getIntValue(prodParamsObj, "smin_address", 4126));
+                prodParamsConfig.setFmaxAddress(getIntValue(prodParamsObj, "fmax_address", 4114));
+                prodParamsConfig.setSmaxAddress(getIntValue(prodParamsObj, "smax_address", 4124));
+                prodParamsConfig.setQtyAddress(getIntValue(prodParamsObj, "qty_address", 4));
+                prodParamsConfig.setUploadInterval(getIntValue(prodParamsObj, "upload_interval", 60));
+            }
+        }
+
+        // 解析UI配置
+        Map<String, Object> uiObj = (Map<String, Object>) config.get("ui");
+        if (uiObj != null) {
+            showMaterialInput = getBooleanValue(uiObj, "show_material_input", false);
+            materialLabel = (String) uiObj.get("material_label");
+
+            // 解析设备信息行配置
+            List<Map<String, Object>> rowsArr = (List<Map<String, Object>>) uiObj.get("device_info_rows");
+            if (rowsArr != null && !rowsArr.isEmpty()) {
+                for (int i = 0; i < Math.min(rowsArr.size(), 2); i++) {
+                    Map<String, Object> row = rowsArr.get(i);
+                    DeviceInfoRow infoRow = new DeviceInfoRow();
+                    infoRow.setLabel((String) row.get("label"));
+                    infoRow.setConnectionIndex(getIntValue(row, "connection_index", 0));
+                    infoRow.setPresetAddress(getIntValue(row, "preset_address", 0));
+                    infoRow.setFinishedAddress(getIntValue(row, "finished_address", 0));
+                    deviceInfoRows.add(infoRow);
+                }
+                showDeviceInfo = true;
+                showDeviceInfo2 = deviceInfoRows.size() > 1;
+            } else {
+                // 兼容旧配置
+                showDeviceInfo = getBooleanValue(uiObj, "show_device_info", false);
+                showDeviceInfo2 = false;
+            }
+        }
+
+        // features 覆盖:prod_params 功能开关生效到 prodParamsConfig
+        if (prodParamsConfig != null && yamlFeatures.containsKey("prod_params")) {
+            boolean effective = isFeatureEnabled("prod_params");
+            if (prodParamsConfig.isEnabled() != effective) {
+                log.info("prod_params 启用状态被 features 覆盖: {} -> {}", prodParamsConfig.isEnabled(), effective);
+                prodParamsConfig.setEnabled(effective);
+            }
+        }
+
+        // 应用 UI 覆盖:设备信息行启用/label
+        ProjectConfigManager pc = ProjectConfigManager.getInstance();
+        applyDeviceRowOverride(0, pc.getDeviceRow1EnabledOverride(), pc.getDeviceRow1LabelOverride());
+        applyDeviceRowOverride(1, pc.getDeviceRow2EnabledOverride(), pc.getDeviceRow2LabelOverride());
+        // showDeviceInfo / showDeviceInfo2 按 enabled 合并重算
+        showDeviceInfo  = !deviceInfoRows.isEmpty() && deviceInfoRows.get(0).isEnabled();
+        showDeviceInfo2 = deviceInfoRows.size() > 1 && deviceInfoRows.get(1).isEnabled();
+        // 物料码输入框显隐覆盖
+        Boolean smiOverride = pc.getShowMaterialInputOverride();
+        if (smiOverride != null) {
+            showMaterialInput = smiOverride;
+        }
+    }
+
+    private void applyDeviceRowOverride(int idx, Boolean enabled, String label) {
+        if (idx < 0 || idx >= deviceInfoRows.size()) return;
+        DeviceInfoRow row = deviceInfoRows.get(idx);
+        if (enabled != null) row.setEnabled(enabled);
+        if (label != null)   row.setLabel(label);
+    }
+
+    /**
+     * 从Map中获取整数值,支持默认值
+     */
+    private int getIntValue(Map<String, Object> map, String key, int defaultValue) {
+        Object value = map.get(key);
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        return defaultValue;
+    }
+
+    /**
+     * 从Map中获取布尔值,支持默认值
+     */
+    private boolean getBooleanValue(Map<String, Object> map, String key, boolean defaultValue) {
+        Object value = map.get(key);
+        if (value instanceof Boolean) {
+            return (Boolean) value;
+        }
+        return defaultValue;
+    }
+
+    // ========== 便捷方法 ==========
+
+    /**
+     * 是否为单工位模式
+     */
+    public boolean isSingleMode() {
+        return "single".equalsIgnoreCase(mode);
+    }
+
+    /**
+     * 是否为双工位模式
+     */
+    public boolean isDualMode() {
+        return "dual".equalsIgnoreCase(mode);
+    }
+
+    /**
+     * 获取工位数量
+     */
+    public int getStationCount() {
+        return stations.size();
+    }
+
+    /**
+     * 根据索引获取工位信息
+     */
+    public StationInfo getStation(int index) {
+        if (index >= 0 && index < stations.size()) {
+            return stations.get(index);
+        }
+        return null;
+    }
+
+    /**
+     * 根据工位号获取工位信息
+     */
+    public StationInfo getStationByCode(String code) {
+        for (StationInfo station : stations) {
+            if (station.getCode().equalsIgnoreCase(code)) {
+                return station;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 获取工位号(兼容旧代码)
+     */
+    public String getStationCode(int index) {
+        StationInfo station = getStation(index);
+        return station != null ? station.getCode() : null;
+    }
+
+    /**
+     * 检查是否有指定功能
+     */
+    public boolean hasFeature(String featureName) {
+        for (StepConfig step : workflowSteps) {
+            if (step.getId().contains(featureName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 检查是否有物料绑定步骤
+     */
+    public boolean hasMaterialBinding() {
+        return hasFeature("scan_material") || hasFeature("bind_material");
+    }
+
+    /**
+     * 检查是否有物料校验步骤
+     */
+    public boolean hasMaterialValidation() {
+        return hasFeature("validate_material");
+    }
+
+    /**
+     * 是否为手动提交模式
+     */
+    public boolean isManualSubmitMode() {
+        return "manual".equalsIgnoreCase(submitMode);
+    }
+
+    /**
+     * 是否为自动提交模式
+     */
+    public boolean isAutoSubmitMode() {
+        return "auto".equalsIgnoreCase(submitMode);
+    }
+
+    /**
+     * 获取提交模式
+     */
+    public String getSubmitMode() {
+        return submitMode;
+    }
+
+    /**
+     * 获取设备连接配置
+     */
+    public DeviceConnection getDeviceConnection(int stationIndex) {
+        for (DeviceConnection conn : deviceConnections) {
+            if (conn.getStationIndex() == stationIndex) {
+                return conn;
+            }
+        }
+        return null;
+    }
+
+    // ========== Getters ==========
+
+    public String getMode() {
+        return mode;
+    }
+
+    public List<StationInfo> getStations() {
+        return stations;
+    }
+
+    public String getLineSn() {
+        return lineSn;
+    }
+
+    /** 运行时覆盖线体号 */
+    public void setLineSn(String lineSn) {
+        this.lineSn = lineSn;
+    }
+
+    public String getServerIp() {
+        return serverIp;
+    }
+
+    /** 运行时覆盖服务器IP */
+    public void setServerIp(String serverIp) {
+        this.serverIp = serverIp;
+    }
+
+    public int getTcpPort() {
+        return tcpPort;
+    }
+
+    public int getHttpPort() {
+        return httpPort;
+    }
+
+    public int getHeartBeatCycle() {
+        return heartBeatCycle;
+    }
+
+    public List<StepConfig> getWorkflowSteps() {
+        return workflowSteps;
+    }
+
+    public boolean isDeviceEnabled() {
+        // 优先使用运行时覆盖
+        Boolean override = ProjectConfigManager.getInstance().getDeviceEnabledOverride();
+        if (override != null) return override;
+        return deviceEnabled;
+    }
+
+    public String getDeviceType() {
+        return deviceType;
+    }
+
+    public List<DeviceConnection> getDeviceConnections() {
+        return deviceConnections;
+    }
+
+    public boolean isTimeoutEnabled() {
+        return timeoutEnabled;
+    }
+
+    public int getConnectTimeout() {
+        return connectTimeout;
+    }
+
+    public int getHeartbeatAddress() {
+        return heartbeatAddress;
+    }
+
+    public int getReadySignalAddress() {
+        return readySignalAddress;
+    }
+
+    public String getStationName() {
+        return stationName;
+    }
+
+    public boolean isShowDeviceInfo() {
+        return showDeviceInfo;
+    }
+
+    public boolean isShowDeviceInfo2() {
+        return showDeviceInfo2;
+    }
+
+    public List<DeviceInfoRow> getDeviceInfoRows() {
+        return deviceInfoRows;
+    }
+
+    public DeviceInfoRow getDeviceInfoRow(int index) {
+        if (index >= 0 && index < deviceInfoRows.size()) {
+            return deviceInfoRows.get(index);
+        }
+        return null;
+    }
+
+    public boolean isShowMaterialInput() {
+        return showMaterialInput;
+    }
+
+    /**
+     * 是否显示物料码输入框
+     * 直接返回 yaml 的 showMaterialInput(不再被 feature 强制覆盖)
+     */
+    public boolean isEffectiveShowMaterialInput() {
+        return showMaterialInput;
+    }
+
+    public String getMaterialLabel() {
+        return materialLabel;
+    }
+
+    public Map<String, Object> getRawConfig() {
+        return rawConfig;
+    }
+
+    public DeviceAddresses getDeviceAddresses() {
+        return deviceAddresses;
+    }
+
+    public CompleteCondition getCompleteCondition() {
+        return completeCondition;
+    }
+
+    public HeartbeatConfig getHeartbeatConfig() {
+        return heartbeatConfig;
+    }
+
+    public RelayConfig getRelayConfig() {
+        return relayConfig;
+    }
+
+    /**
+     * 检查是否配置了心跳
+     */
+    public boolean hasHeartbeat() {
+        return heartbeatConfig != null && heartbeatConfig.isEnabled();
+    }
+
+    /**
+     * 检查是否配置了继电器
+     */
+    public boolean hasRelay() {
+        return relayConfig != null && relayConfig.isEnabled();
+    }
+
+    public ProdParamsConfig getProdParamsConfig() {
+        return prodParamsConfig;
+    }
+
+    /**
+     * 检查是否启用过程参数采集
+     */
+    public boolean hasProdParams() {
+        return prodParamsConfig != null && prodParamsConfig.isEnabled();
+    }
+
+    // ========== 内部类 ==========
+
+    /**
+     * 工位信息
+     */
+    public static class StationInfo {
+        private int index;
+        private String code;
+
+        public StationInfo(int index, String code) {
+            this.index = index;
+            this.code = code;
+        }
+
+        public int getIndex() {
+            return index;
+        }
+
+        public String getCode() {
+            return code;
+        }
+
+        /** 运行时覆盖工位号 */
+        public void setCode(String code) {
+            this.code = code;
+        }
+
+        @Override
+        public String toString() {
+            return "StationInfo{code='" + code + "'}";
+        }
+    }
+
+    /**
+     * 步骤配置
+     */
+    public static class StepConfig {
+        private String id;
+        private String name;
+        private String craft;
+        private Map<String, Object> config;
+
+        public StepConfig() {
+            this.config = new HashMap<>();
+        }
+
+        public String getId() {
+            return id;
+        }
+
+        public void setId(String id) {
+            this.id = id;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public void setName(String name) {
+            this.name = name;
+        }
+
+        public String getCraft() {
+            return craft;
+        }
+
+        public void setCraft(String craft) {
+            this.craft = craft;
+        }
+
+        public Map<String, Object> getConfig() {
+            return config;
+        }
+
+        public void setConfig(Map<String, Object> config) {
+            this.config = config != null ? config : new HashMap<>();
+        }
+
+        public String getConfigString(String key) {
+            Object value = config.get(key);
+            return value != null ? value.toString() : null;
+        }
+
+        public boolean getConfigBoolean(String key, boolean defaultValue) {
+            Object value = config.get(key);
+            if (value instanceof Boolean) {
+                return (Boolean) value;
+            }
+            return defaultValue;
+        }
+
+        @Override
+        public String toString() {
+            return "StepConfig{id='" + id + "', name='" + name + "'}";
+        }
+    }
+
+    /**
+     * 设备连接配置
+     */
+    public static class DeviceConnection {
+        private int stationIndex;
+        private String ip;
+        private int port;
+
+        public DeviceConnection(int stationIndex, String ip, int port) {
+            this.stationIndex = stationIndex;
+            this.ip = ip;
+            this.port = port;
+        }
+
+        public int getStationIndex() {
+            return stationIndex;
+        }
+
+        public String getIp() {
+            return ip;
+        }
+
+        /** 运行时修改IP(用于软件内切换设备IP) */
+        public void setIp(String ip) {
+            this.ip = ip;
+        }
+
+        public int getPort() {
+            return port;
+        }
+
+        @Override
+        public String toString() {
+            return "DeviceConnection{stationIndex=" + stationIndex + ", ip='" + ip + "', port=" + port + "}";
+        }
+    }
+
+    /**
+     * 设备地址配置
+     */
+    public static class DeviceAddresses {
+        private String enableSignal;      // 允许信号地址(S7格式如 DB9.18.1)
+        private String startSignal;       // 启动信号地址
+        private String completeSignal;    // 完成信号地址
+        private String deviceStatus;      // 设备状态地址(Modbus格式如 15001)
+        private int finishedCount;        // 完成数量地址
+        private int presetCount;          // 预设数量地址
+        private int qualifiedCount;       // 合格数量地址
+        private int startCommand;         // 启动命令地址
+
+        public String getEnableSignal() { return enableSignal; }
+        public void setEnableSignal(String enableSignal) { this.enableSignal = enableSignal; }
+        public String getStartSignal() { return startSignal; }
+        public void setStartSignal(String startSignal) { this.startSignal = startSignal; }
+        public String getCompleteSignal() { return completeSignal; }
+        public void setCompleteSignal(String completeSignal) { this.completeSignal = completeSignal; }
+        public String getDeviceStatus() { return deviceStatus; }
+        public void setDeviceStatus(String deviceStatus) { this.deviceStatus = deviceStatus; }
+        public int getFinishedCount() { return finishedCount; }
+        public void setFinishedCount(int finishedCount) { this.finishedCount = finishedCount; }
+        public int getPresetCount() { return presetCount; }
+        public void setPresetCount(int presetCount) { this.presetCount = presetCount; }
+        public int getQualifiedCount() { return qualifiedCount; }
+        public void setQualifiedCount(int qualifiedCount) { this.qualifiedCount = qualifiedCount; }
+        public int getStartCommand() { return startCommand; }
+        public void setStartCommand(int startCommand) { this.startCommand = startCommand; }
+    }
+
+    /**
+     * 完成条件配置
+     */
+    public static class CompleteCondition {
+        private String type = "signal";   // signal | count | signal_with_time
+        private int runningValue = 1;     // 运行中的值
+        private int stoppedValue = 0;     // 停止的值
+        private int minRunningTime = 0;   // 最小运行时间(ms)
+
+        public String getType() { return type; }
+        public void setType(String type) { this.type = type != null ? type : "signal"; }
+        public int getRunningValue() { return runningValue; }
+        public void setRunningValue(int runningValue) { this.runningValue = runningValue; }
+        public int getStoppedValue() { return stoppedValue; }
+        public void setStoppedValue(int stoppedValue) { this.stoppedValue = stoppedValue; }
+        public int getMinRunningTime() { return minRunningTime; }
+        public void setMinRunningTime(int minRunningTime) { this.minRunningTime = minRunningTime; }
+    }
+
+    /**
+     * 心跳配置
+     */
+    public static class HeartbeatConfig {
+        private boolean enabled = true;
+        private int address = 4160;
+        private int interval = 500;       // 心跳间隔(ms)
+        private int timeoutAddress = 4170;  // 心跳超时寄存器地址(PLC 读这个值判断超时阈值)
+        private int timeoutValue = 10;    // 心跳超时阈值(单位100ms,10=1秒)
+
+        public boolean isEnabled() { return enabled; }
+        public void setEnabled(boolean enabled) { this.enabled = enabled; }
+        public int getAddress() { return address; }
+        public void setAddress(int address) { this.address = address; }
+        public int getInterval() { return interval; }
+        public void setInterval(int interval) { this.interval = interval; }
+        public int getTimeoutAddress() { return timeoutAddress; }
+        public void setTimeoutAddress(int timeoutAddress) { this.timeoutAddress = timeoutAddress; }
+        public int getTimeoutValue() { return timeoutValue; }
+        public void setTimeoutValue(int timeoutValue) { this.timeoutValue = timeoutValue; }
+    }
+
+    /**
+     * 继电器配置
+     */
+    public static class RelayConfig {
+        private boolean enabled = true;
+        private String type;              // modbus_rtu
+        private String port;              // COM2
+        private int baudrate = 38400;
+        private String openCommand;       // 开继电器命令(十六进制)
+        private String closeCommand;      // 关继电器命令(十六进制)
+
+        public boolean isEnabled() { return enabled; }
+        public void setEnabled(boolean enabled) { this.enabled = enabled; }
+        public String getType() { return type; }
+        public void setType(String type) { this.type = type; }
+        public String getPort() { return port; }
+        public void setPort(String port) { this.port = port; }
+        public int getBaudrate() { return baudrate; }
+        public void setBaudrate(int baudrate) { this.baudrate = baudrate; }
+        public String getOpenCommand() { return openCommand; }
+        public void setOpenCommand(String openCommand) { this.openCommand = openCommand; }
+        public String getCloseCommand() { return closeCommand; }
+        public void setCloseCommand(String closeCommand) { this.closeCommand = closeCommand; }
+    }
+
+    /**
+     * 设备信息行配置
+     */
+    public static class DeviceInfoRow {
+        private String label;               // 可选前置标签(如"M6"),为空或null则不显示
+        private int connectionIndex;        // 对应 connections 中的索引,指定从哪个设备读数据
+        private int presetAddress;           // 预设数量寄存器地址
+        private int finishedAddress;         // 完成数量寄存器地址
+        private boolean enabled = true;      // 是否启用(UI 覆盖用)
+
+        public String getLabel() { return label; }
+        public void setLabel(String label) { this.label = label; }
+        public int getConnectionIndex() { return connectionIndex; }
+        public void setConnectionIndex(int connectionIndex) { this.connectionIndex = connectionIndex; }
+        public int getPresetAddress() { return presetAddress; }
+        public void setPresetAddress(int presetAddress) { this.presetAddress = presetAddress; }
+        public int getFinishedAddress() { return finishedAddress; }
+        public void setFinishedAddress(int finishedAddress) { this.finishedAddress = finishedAddress; }
+        public boolean isEnabled() { return enabled; }
+        public void setEnabled(boolean enabled) { this.enabled = enabled; }
+
+        public boolean hasLabel() {
+            return label != null && !label.trim().isEmpty();
+        }
+    }
+
+    /**
+     * 过程参数采集配置
+     */
+    public static class ProdParamsConfig {
+        private boolean enabled;
+        private int foutAddress = 4112;   // 力输出值 F-out
+        private int soutAddress = 4120;   // 行程输出值 S-out
+        private int fminAddress = 4116;   // 力最小值 F-min
+        private int sminAddress = 4126;   // 行程最小值 S-min
+        private int fmaxAddress = 4114;   // 力最大值 F-max
+        private int smaxAddress = 4124;   // 行程最大值 S-max
+        private int qtyAddress = 4;       // 数量寄存器地址
+        private int uploadInterval = 60;  // 上传间隔(秒)
+
+        public boolean isEnabled() { return enabled; }
+        public void setEnabled(boolean enabled) { this.enabled = enabled; }
+        public int getFoutAddress() { return foutAddress; }
+        public void setFoutAddress(int foutAddress) { this.foutAddress = foutAddress; }
+        public int getSoutAddress() { return soutAddress; }
+        public void setSoutAddress(int soutAddress) { this.soutAddress = soutAddress; }
+        public int getFminAddress() { return fminAddress; }
+        public void setFminAddress(int fminAddress) { this.fminAddress = fminAddress; }
+        public int getSminAddress() { return sminAddress; }
+        public void setSminAddress(int sminAddress) { this.sminAddress = sminAddress; }
+        public int getFmaxAddress() { return fmaxAddress; }
+        public void setFmaxAddress(int fmaxAddress) { this.fmaxAddress = fmaxAddress; }
+        public int getSmaxAddress() { return smaxAddress; }
+        public void setSmaxAddress(int smaxAddress) { this.smaxAddress = smaxAddress; }
+        public int getQtyAddress() { return qtyAddress; }
+        public void setQtyAddress(int qtyAddress) { this.qtyAddress = qtyAddress; }
+        public int getUploadInterval() { return uploadInterval; }
+        public void setUploadInterval(int uploadInterval) { this.uploadInterval = uploadInterval; }
+    }
+}

+ 696 - 0
src/com/mes/core/StationContext.java

@@ -0,0 +1,696 @@
+package com.mes.core;
+
+import com.mes.device.IDeviceDriver;
+import com.mes.ui.component.WorkstationPanel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 工位上下文 - 管理单个工位的所有状态信息
+ * 解决状态分散、全局变量污染问题
+ */
+public class StationContext {
+    private static final Logger log = LoggerFactory.getLogger(StationContext.class);
+
+    // ========== 工位标识 ==========
+    private final int stationIndex;         // 工位索引 0=工位1, 1=工位2
+    private String stationCode;             // 工位号 OP150A(从配置读取,支持运行时修改)
+    private String stationName;             // 工位名称(支持运行时修改)
+    private String lineSn;                  // 线体号 XT(支持运行时修改)
+
+    // ========== 当前工作数据 ==========
+    private String productSn;               // 工件码
+    private String materialSn;              // 物料码(底护板/冷板)
+    private String user;                    // 操作用户
+
+    // ========== 流程状态 ==========
+    private int currentStepIndex;           // 当前步骤索引
+    private WorkflowState state;            // 流程状态
+    private String lastError;               // 最后错误信息
+
+    // ========== 检查状态(带注解,支持调试控制) ==========
+    @WorkflowCondition(name = "质量检查通过", description = "质量检查是否通过", 
+            group = "检查状态", affectedSteps = {"start_work", "device_start", "upload_result"})
+    private boolean qualityPassed;          // 质量是否通过
+    
+    @WorkflowCondition(name = "物料已校验", description = "物料是否校验通过", 
+            group = "检查状态", affectedSteps = {"bind_material"})
+    private boolean materialValidated;      // 物料是否校验通过
+    
+    @WorkflowCondition(name = "物料已绑定", description = "物料是否已绑定", 
+            group = "检查状态", affectedSteps = {"bind_material"})
+    private boolean materialBound;          // 物料是否已绑定
+    
+    @WorkflowCondition(name = "工作已开始", description = "是否已开始工作", 
+            group = "流程状态", affectedSteps = {"wait_complete", "upload_result"})
+    private boolean workStarted;            // 是否已开始工作
+    
+    @WorkflowCondition(name = "结果已上传", description = "结果是否已上传", 
+            group = "流程状态", affectedSteps = {"upload_result"})
+    private boolean resultUploaded;         // 结果是否已上传
+    
+    @WorkflowCondition(name = "正在提交", description = "是否正在提交中", 
+            group = "流程状态", affectedSteps = {"upload_result"})
+    private boolean isSubmitting;           // 是否正在提交
+    
+    @WorkflowCondition(name = "等待用户操作", description = "是否等待用户点击OK/NG", 
+            group = "流程状态", affectedSteps = {"start_work"})
+    private boolean waitingForUserAction;   // 是否等待用户操作(手动模式)
+
+    // ========== 设备相关 ==========
+    private IDeviceDriver deviceDriver;     // 设备驱动实例
+    private IDeviceDriver deviceDriver2;    // 第二行设备驱动实例(可能来自不同IP的设备)
+    private com.mes.device.RelayDriver relayDriver;  // 继电器驱动实例
+    
+    @WorkflowCondition(name = "设备启用", description = "设备是否启用", 
+            group = "设备状态", affectedSteps = {"device_start", "wait_complete"})
+    private boolean deviceEnabled;          // 设备是否启用
+    
+    @WorkflowCondition(name = "心跳激活", description = "设备心跳是否激活", 
+            group = "设备状态", affectedSteps = {})
+    private boolean heartbeatActive;        // 心跳是否激活
+    
+    @WorkflowCondition(name = "设备已启动", description = "设备是否已启动", 
+            group = "设备状态", affectedSteps = {"wait_device_complete"})
+    private boolean deviceStarted;          // 设备是否已启动
+    
+    // 设备运行时间记录(用于signal_with_time完成条件)
+    private long deviceStartTime;           // 设备启动时间戳
+    
+    // ========== 设备数据(拉铆等) ==========
+    @WorkflowCondition(name = "预设数量", description = "拉铆预设数量", 
+            group = "设备数据", affectedSteps = {"wait_device_complete"})
+    private int presetCount;                // 预设数量
+    
+    @WorkflowCondition(name = "完成数量", description = "拉铆完成数量", 
+            group = "设备数据", affectedSteps = {"wait_device_complete"})
+    private int finishedCount;              // 完成数量
+    
+    @WorkflowCondition(name = "合格数量", description = "拉铆合格数量", 
+            group = "设备数据", affectedSteps = {"wait_device_complete"})
+    private int qualifiedCount;             // 合格数量
+
+    // ========== 设备数据第二行 ==========
+    private int presetCount2;               // 第二行预设数量
+    private int finishedCount2;             // 第二行完成数量
+    private boolean deviceConnected2;       // 第二行设备连接状态
+
+    // ========== UI相关 ==========
+    private WorkstationPanel uiPanel;       // 对应的UI面板
+    private String statusMessage;           // 状态消息
+    private int statusLevel;                // 状态级别 0=正常 1=警告 -1=错误
+
+    // ========== 扩展数据 ==========
+    private Map<String, Object> extraData;  // 扩展数据存储
+
+    // ========== 回调接口 ==========
+    private ContextChangeListener changeListener;
+    
+    // ========== 调试拦截器 ==========
+    private ConditionInterceptor conditionInterceptor;
+
+    /**
+     * 流程状态枚举
+     */
+    public enum WorkflowState {
+        IDLE,           // 空闲,等待扫码
+        SCANNING,       // 正在扫码
+        CHECKING,       // 正在检查(质量/物料)
+        WORKING,        // 工作中
+        WAITING,        // 等待设备完成
+        UPLOADING,      // 正在上传结果
+        COMPLETED,      // 已完成
+        ERROR           // 错误状态
+    }
+
+    /**
+     * 状态变化监听器
+     */
+    public interface ContextChangeListener {
+        void onStateChanged(StationContext context, WorkflowState oldState, WorkflowState newState);
+        void onStatusMessageChanged(StationContext context, String message, int level);
+        void onDataChanged(StationContext context, String field, Object oldValue, Object newValue);
+    }
+
+    /**
+     * 构造函数
+     */
+    public StationContext(int stationIndex, String stationCode, String stationName, String lineSn) {
+        this.stationIndex = stationIndex;
+        this.stationCode = stationCode;
+        this.stationName = stationName != null ? stationName : stationCode;
+        this.lineSn = lineSn;
+        this.extraData = new ConcurrentHashMap<>();
+        this.state = WorkflowState.IDLE;
+        this.currentStepIndex = 0;
+        this.statusMessage = "请扫描工件码";
+        this.statusLevel = 0;
+    }
+
+    /**
+     * 从配置创建上下文
+     */
+    public static StationContext fromConfig(StationConfig.StationInfo stationInfo, String lineSn) {
+        // 工位名:按当前产线位置从 OprnoRegistry 查;查不到 fallback 到工位号
+        String lineId = ProjectConfigManager.getInstance().getCurrentLineId();
+        String name = OprnoRegistry.getGwDes(lineId, stationInfo.getCode());
+        if (name == null || name.isEmpty()) {
+            name = stationInfo.getCode();
+        }
+        return new StationContext(
+                stationInfo.getIndex(),
+                stationInfo.getCode(),
+                name,
+                lineSn
+        );
+    }
+
+    // ========== 状态管理方法 ==========
+
+    /**
+     * 重置到初始状态(完成一件后重置)
+     * 注意:不重置 currentStepIndex,由流程引擎控制
+     */
+    public void reset() {
+        log.info("[{}] 重置工位状态", stationCode);
+
+        // 清空工作数据
+        this.productSn = null;
+        this.materialSn = null;
+
+        // 重置流程状态(不重置 currentStepIndex)
+        setState(WorkflowState.IDLE);
+        this.lastError = null;
+
+        // 重置检查状态
+        this.qualityPassed = false;
+        this.materialValidated = false;
+        this.materialBound = false;
+        this.workStarted = false;
+        this.resultUploaded = false;
+        this.isSubmitting = false;
+        this.waitingForUserAction = false;
+
+        // 重置设备状态
+        this.heartbeatActive = false;
+        this.deviceStarted = false;
+        this.deviceStartTime = 0;
+        this.presetCount = 0;
+        this.finishedCount = 0;
+        this.qualifiedCount = 0;
+        this.presetCount2 = 0;
+        this.finishedCount2 = 0;
+        this.deviceConnected2 = false;
+
+        // 清空扩展数据
+        this.extraData.clear();
+
+        // 设置初始提示
+        setStatusMessage("请扫描工件码", 0);
+
+        // 刷新UI
+        updateUI();
+    }
+
+    /**
+     * 设置流程状态
+     */
+    public void setState(WorkflowState newState) {
+        WorkflowState oldState = this.state;
+        this.state = newState;
+        log.debug("[{}] 状态变更: {} -> {}", stationCode, oldState, newState);
+
+        if (changeListener != null && oldState != newState) {
+            changeListener.onStateChanged(this, oldState, newState);
+        }
+    }
+
+    /**
+     * 设置状态消息
+     */
+    public void setStatusMessage(String message, int level) {
+        this.statusMessage = message;
+        this.statusLevel = level;
+        log.info("[{}] 状态消息: {} (level={})", stationCode, message, level);
+
+        if (changeListener != null) {
+            changeListener.onStatusMessageChanged(this, message, level);
+        }
+
+        // 刷新UI状态栏
+        if (uiPanel != null) {
+            uiPanel.updateStatus(message, level);
+        }
+    }
+
+    /**
+     * 刷新UI显示
+     */
+    public void updateUI() {
+        if (uiPanel != null) {
+            uiPanel.refresh(this);
+        }
+    }
+
+    // ========== 流程控制方法 ==========
+
+    /**
+     * 进入下一步
+     */
+    public void nextStep() {
+        currentStepIndex++;
+        log.debug("[{}] 进入步骤 {}", stationCode, currentStepIndex);
+    }
+
+    /**
+     * 回退到上一步
+     */
+    public void previousStep() {
+        if (currentStepIndex > 0) {
+            currentStepIndex--;
+            log.debug("[{}] 回退到步骤 {}", stationCode, currentStepIndex);
+        }
+    }
+
+    /**
+     * 跳转到指定步骤
+     */
+    public void goToStep(int stepIndex) {
+        this.currentStepIndex = stepIndex;
+        log.debug("[{}] 跳转到步骤 {}", stationCode, stepIndex);
+    }
+
+    /**
+     * 标记错误
+     */
+    public void markError(String errorMessage) {
+        this.lastError = errorMessage;
+        setState(WorkflowState.ERROR);
+        setStatusMessage(errorMessage, -1);
+    }
+
+    // ========== 数据访问方法 ==========
+
+    /**
+     * 设置工件码
+     */
+    public void setProductSn(String productSn) {
+        String oldValue = this.productSn;
+        this.productSn = productSn;
+        log.info("[{}] 工件码: {}", stationCode, productSn);
+
+        if (changeListener != null) {
+            changeListener.onDataChanged(this, "productSn", oldValue, productSn);
+        }
+    }
+
+    /**
+     * 设置物料码
+     */
+    public void setMaterialSn(String materialSn) {
+        String oldValue = this.materialSn;
+        this.materialSn = materialSn;
+        log.info("[{}] 物料码: {}", stationCode, materialSn);
+
+        if (changeListener != null) {
+            changeListener.onDataChanged(this, "materialSn", oldValue, materialSn);
+        }
+    }
+
+    /**
+     * 获取处理后的工件码(36位码处理)
+     */
+    public String getProcessedProductSn() {
+        if (productSn == null) {
+            return null;
+        }
+        // 36位码取前20位
+        if (productSn.length() == 36) {
+            return productSn.substring(0, 20);
+        }
+        return productSn;
+    }
+
+    /**
+     * 存储扩展数据
+     */
+    public void setExtra(String key, Object value) {
+        extraData.put(key, value);
+    }
+
+    /**
+     * 获取扩展数据
+     */
+    @SuppressWarnings("unchecked")
+    public <T> T getExtra(String key, T defaultValue) {
+        Object value = extraData.get(key);
+        if (value == null) {
+            return defaultValue;
+        }
+        try {
+            return (T) value;
+        } catch (ClassCastException e) {
+            return defaultValue;
+        }
+    }
+
+    /**
+     * 获取扩展数据(字符串)
+     */
+    public String getExtraString(String key) {
+        Object value = extraData.get(key);
+        return value != null ? value.toString() : null;
+    }
+
+    // ========== Getters & Setters ==========
+
+    public int getStationIndex() {
+        return stationIndex;
+    }
+
+    public String getStationCode() {
+        return stationCode;
+    }
+
+    public String getStationName() {
+        return stationName;
+    }
+
+    /** 运行时修改工位信息(仅改工位号/名称/线体时使用,慎用) */
+    public void updateStation(String stationCode, String stationName, String lineSn) {
+        if (stationCode != null) this.stationCode = stationCode;
+        if (stationName != null) this.stationName = stationName;
+        if (lineSn != null) this.lineSn = lineSn;
+    }
+
+    public String getLineSn() {
+        return lineSn;
+    }
+
+    public String getProductSn() {
+        return productSn;
+    }
+
+    public String getMaterialSn() {
+        return materialSn;
+    }
+
+    public String getUser() {
+        return user;
+    }
+
+    public void setUser(String user) {
+        this.user = user;
+    }
+
+    public int getCurrentStepIndex() {
+        return currentStepIndex;
+    }
+
+    public void setCurrentStepIndex(int currentStepIndex) {
+        this.currentStepIndex = currentStepIndex;
+    }
+
+    public WorkflowState getState() {
+        return state;
+    }
+
+    public String getLastError() {
+        return lastError;
+    }
+
+    public boolean isQualityPassed() {
+        return interceptBooleanGet("qualityPassed", qualityPassed);
+    }
+
+    public void setQualityPassed(boolean qualityPassed) {
+        this.qualityPassed = interceptBoolean("qualityPassed", qualityPassed);
+    }
+
+    public boolean isMaterialValidated() {
+        return interceptBooleanGet("materialValidated", materialValidated);
+    }
+
+    public void setMaterialValidated(boolean materialValidated) {
+        this.materialValidated = interceptBoolean("materialValidated", materialValidated);
+    }
+
+    public boolean isMaterialBound() {
+        return interceptBooleanGet("materialBound", materialBound);
+    }
+
+    public void setMaterialBound(boolean materialBound) {
+        this.materialBound = interceptBoolean("materialBound", materialBound);
+    }
+
+    public boolean isWorkStarted() {
+        return interceptBooleanGet("workStarted", workStarted);
+    }
+
+    public void setWorkStarted(boolean workStarted) {
+        this.workStarted = interceptBoolean("workStarted", workStarted);
+    }
+
+    public boolean isResultUploaded() {
+        return interceptBooleanGet("resultUploaded", resultUploaded);
+    }
+
+    public void setResultUploaded(boolean resultUploaded) {
+        this.resultUploaded = interceptBoolean("resultUploaded", resultUploaded);
+    }
+
+    public boolean isSubmitting() {
+        return interceptBooleanGet("isSubmitting", isSubmitting);
+    }
+
+    public void setSubmitting(boolean submitting) {
+        isSubmitting = interceptBoolean("isSubmitting", submitting);
+    }
+
+    public boolean isWaitingForUserAction() {
+        return interceptBooleanGet("waitingForUserAction", waitingForUserAction);
+    }
+
+    public void setWaitingForUserAction(boolean waitingForUserAction) {
+        this.waitingForUserAction = interceptBoolean("waitingForUserAction", waitingForUserAction);
+    }
+
+    public IDeviceDriver getDeviceDriver() {
+        return deviceDriver;
+    }
+
+    public void setDeviceDriver(IDeviceDriver deviceDriver) {
+        this.deviceDriver = deviceDriver;
+    }
+
+    public IDeviceDriver getDeviceDriver2() {
+        return deviceDriver2;
+    }
+
+    public void setDeviceDriver2(IDeviceDriver deviceDriver2) {
+        this.deviceDriver2 = deviceDriver2;
+    }
+
+    public boolean isDeviceEnabled() {
+        return interceptBooleanGet("deviceEnabled", deviceEnabled);
+    }
+
+    public void setDeviceEnabled(boolean deviceEnabled) {
+        this.deviceEnabled = interceptBoolean("deviceEnabled", deviceEnabled);
+    }
+
+    public boolean isHeartbeatActive() {
+        return interceptBooleanGet("heartbeatActive", heartbeatActive);
+    }
+
+    public void setHeartbeatActive(boolean heartbeatActive) {
+        this.heartbeatActive = interceptBoolean("heartbeatActive", heartbeatActive);
+    }
+
+    public boolean isDeviceStarted() {
+        return interceptBooleanGet("deviceStarted", deviceStarted);
+    }
+
+    public void setDeviceStarted(boolean deviceStarted) {
+        this.deviceStarted = interceptBoolean("deviceStarted", deviceStarted);
+        if (deviceStarted && deviceStartTime == 0) {
+            this.deviceStartTime = System.currentTimeMillis();
+        }
+    }
+
+    public long getDeviceStartTime() {
+        return deviceStartTime;
+    }
+
+    public void setDeviceStartTime(long deviceStartTime) {
+        this.deviceStartTime = deviceStartTime;
+    }
+
+    /**
+     * 获取设备运行时间(毫秒)
+     */
+    public long getDeviceRunningTime() {
+        if (deviceStartTime == 0) {
+            return 0;
+        }
+        return System.currentTimeMillis() - deviceStartTime;
+    }
+
+    public com.mes.device.RelayDriver getRelayDriver() {
+        return relayDriver;
+    }
+
+    public void setRelayDriver(com.mes.device.RelayDriver relayDriver) {
+        this.relayDriver = relayDriver;
+    }
+
+    public int getPresetCount() {
+        return interceptIntGet("presetCount", presetCount);
+    }
+
+    public void setPresetCount(int presetCount) {
+        this.presetCount = interceptInt("presetCount", presetCount);
+    }
+
+    public int getFinishedCount() {
+        return interceptIntGet("finishedCount", finishedCount);
+    }
+
+    public void setFinishedCount(int finishedCount) {
+        this.finishedCount = interceptInt("finishedCount", finishedCount);
+    }
+
+    public int getQualifiedCount() {
+        return interceptIntGet("qualifiedCount", qualifiedCount);
+    }
+
+    public void setQualifiedCount(int qualifiedCount) {
+        this.qualifiedCount = interceptInt("qualifiedCount", qualifiedCount);
+    }
+
+    public int getPresetCount2() {
+        return presetCount2;
+    }
+
+    public void setPresetCount2(int presetCount2) {
+        this.presetCount2 = presetCount2;
+    }
+
+    public int getFinishedCount2() {
+        return finishedCount2;
+    }
+
+    public void setFinishedCount2(int finishedCount2) {
+        this.finishedCount2 = finishedCount2;
+    }
+
+    public boolean isDeviceConnected2() {
+        return deviceConnected2;
+    }
+
+    public void setDeviceConnected2(boolean deviceConnected2) {
+        this.deviceConnected2 = deviceConnected2;
+    }
+    
+    // ========== 拦截器相关方法 ==========
+    
+    /**
+     * 设置条件拦截器
+     */
+    public void setConditionInterceptor(ConditionInterceptor interceptor) {
+        this.conditionInterceptor = interceptor;
+    }
+    
+    /**
+     * 获取条件拦截器
+     */
+    public ConditionInterceptor getConditionInterceptor() {
+        return conditionInterceptor;
+    }
+    
+    /**
+     * 拦截布尔值(setter用)
+     */
+    private boolean interceptBoolean(String fieldName, boolean value) {
+        if (conditionInterceptor != null) {
+            return conditionInterceptor.intercept(fieldName, value);
+        }
+        return value;
+    }
+    
+    /**
+     * 拦截整数值(setter用)
+     */
+    private int interceptInt(String fieldName, int value) {
+        if (conditionInterceptor != null) {
+            return conditionInterceptor.intercept(fieldName, value);
+        }
+        return value;
+    }
+    
+    /**
+     * 拦截布尔值读取(getter用)
+     * 如果有调试规则,返回规则值;否则返回实际值
+     */
+    private boolean interceptBooleanGet(String fieldName, boolean actualValue) {
+        if (conditionInterceptor != null && conditionInterceptor.hasRule(fieldName)) {
+            Object ruleValue = conditionInterceptor.getRuleValue(fieldName);
+            if (ruleValue instanceof Boolean) {
+                return (Boolean) ruleValue;
+            }
+        }
+        return actualValue;
+    }
+    
+    /**
+     * 拦截整数值读取(getter用)
+     * 如果有调试规则,返回规则值;否则返回实际值
+     */
+    private int interceptIntGet(String fieldName, int actualValue) {
+        if (conditionInterceptor != null && conditionInterceptor.hasRule(fieldName)) {
+            Object ruleValue = conditionInterceptor.getRuleValue(fieldName);
+            if (ruleValue instanceof Number) {
+                return ((Number) ruleValue).intValue();
+            }
+        }
+        return actualValue;
+    }
+
+    public WorkstationPanel getUiPanel() {
+        return uiPanel;
+    }
+
+    public void setUiPanel(WorkstationPanel uiPanel) {
+        this.uiPanel = uiPanel;
+    }
+
+    public String getStatusMessage() {
+        return statusMessage;
+    }
+
+    public int getStatusLevel() {
+        return statusLevel;
+    }
+
+    public ContextChangeListener getChangeListener() {
+        return changeListener;
+    }
+
+    public void setChangeListener(ContextChangeListener changeListener) {
+        this.changeListener = changeListener;
+    }
+
+    @Override
+    public String toString() {
+        return "StationContext{" +
+                "stationCode='" + stationCode + '\'' +
+                ", state=" + state +
+                ", productSn='" + productSn + '\'' +
+                ", currentStep=" + currentStepIndex +
+                '}';
+    }
+}

+ 105 - 0
src/com/mes/core/UiPrefs.java

@@ -0,0 +1,105 @@
+package com.mes.core;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONWriter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 本机 UI 偏好 - 管理“始终显示”的菜单白名单
+ *
+ * 存储位置:config/ui_prefs.json
+ * 结构:
+ * {
+ *   "alwaysShow": ["manual_submit"]
+ * }
+ */
+public class UiPrefs {
+    private static final Logger log = LoggerFactory.getLogger(UiPrefs.class);
+    private static final String CONFIG_FILE = "config/ui_prefs.json";
+
+    /** 菜单 key:手动提交子菜单 */
+    public static final String KEY_MANUAL_SUBMIT = "manual_submit";
+
+    private static UiPrefs INSTANCE;
+
+    private final Path configPath;
+    private final Set<String> alwaysShow = new HashSet<>();
+
+    private UiPrefs() {
+        this.configPath = Paths.get(CONFIG_FILE);
+        load();
+    }
+
+    public static synchronized UiPrefs getInstance() {
+        if (INSTANCE == null) {
+            INSTANCE = new UiPrefs();
+        }
+        return INSTANCE;
+    }
+
+    /** 是否勾选了“始终显示” */
+    public boolean isAlwaysShow(String key) {
+        return alwaysShow.contains(key);
+    }
+
+    /** 设置“始终显示”状态,立即落盘 */
+    public void setAlwaysShow(String key, boolean always) {
+        boolean changed;
+        if (always) {
+            changed = alwaysShow.add(key);
+        } else {
+            changed = alwaysShow.remove(key);
+        }
+        if (changed) {
+            save();
+        }
+    }
+
+    private void load() {
+        try {
+            if (!Files.exists(configPath)) {
+                log.info("UI偏好文件不存在,使用默认值: {}", configPath);
+                return;
+            }
+            String content = new String(Files.readAllBytes(configPath), StandardCharsets.UTF_8);
+            JSONObject json = JSON.parseObject(content);
+            if (json == null) return;
+
+            alwaysShow.clear();
+            java.util.List<Object> list = json.getJSONArray("alwaysShow");
+            if (list != null) {
+                for (Object o : list) {
+                    if (o != null) alwaysShow.add(o.toString());
+                }
+            }
+            log.info("UI偏好加载成功: alwaysShow={}", alwaysShow);
+        } catch (Exception e) {
+            log.error("加载UI偏好失败: {}", e.getMessage(), e);
+        }
+    }
+
+    private void save() {
+        try {
+            if (configPath.getParent() != null && !Files.exists(configPath.getParent())) {
+                Files.createDirectories(configPath.getParent());
+            }
+            JSONObject root = new JSONObject();
+            root.put("alwaysShow", new java.util.ArrayList<>(alwaysShow));
+            String content = JSON.toJSONString(root, JSONWriter.Feature.PrettyFormat);
+            Files.write(configPath, content.getBytes(StandardCharsets.UTF_8));
+            log.info("UI偏好已保存: {}", alwaysShow);
+        } catch (IOException e) {
+            log.error("保存UI偏好失败: {}", e.getMessage(), e);
+        }
+    }
+}

+ 38 - 0
src/com/mes/core/WorkflowCondition.java

@@ -0,0 +1,38 @@
+package com.mes.core;
+
+import java.lang.annotation.*;
+
+/**
+ * 流程条件注解 - 标记影响流程执行的字段
+ * 用于调试面板自动识别可控制的条件变量
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+@Documented
+public @interface WorkflowCondition {
+    
+    /**
+     * 条件名称(用于显示)
+     */
+    String name();
+    
+    /**
+     * 条件描述
+     */
+    String description() default "";
+    
+    /**
+     * 影响的步骤ID列表(可选,用于提示)
+     */
+    String[] affectedSteps() default {};
+    
+    /**
+     * 条件分组(用于UI分组显示)
+     */
+    String group() default "default";
+    
+    /**
+     * 是否可以被调试控制
+     */
+    boolean controllable() default true;
+}

+ 186 - 0
src/com/mes/device/IDeviceDriver.java

@@ -0,0 +1,186 @@
+package com.mes.device;
+
+import java.util.Map;
+
+/**
+ * 设备驱动接口 - 抽象设备通信行为
+ * 支持Modbus TCP、Modbus RTU、S7等协议
+ */
+public interface IDeviceDriver {
+
+    /**
+     * 连接设备
+     * @param config 连接配置(ip, port, 串口等)
+     */
+    void connect(Map<String, Object> config);
+
+    /**
+     * 断开连接
+     */
+    void disconnect();
+
+    /**
+     * 检查是否已连接
+     */
+    boolean isConnected();
+
+    /**
+     * 发送就绪信号(通知设备可以开始加工)
+     */
+    void signalReady();
+
+    /**
+     * 启动心跳
+     */
+    void startHeartbeat();
+
+    /**
+     * 停止心跳
+     */
+    void stopHeartbeat();
+
+    /**
+     * 读取设备状态
+     * @return 状态数据Map
+     */
+    Map<String, Object> readStatus();
+
+    /**
+     * 检查加工是否完成
+     */
+    boolean isWorkComplete();
+
+    /**
+     * 获取预设数量
+     */
+    int getPresetCount();
+
+    /**
+     * 获取完成数量
+     */
+    int getFinishedCount();
+
+    /**
+     * 获取合格数量
+     */
+    int getQualifiedCount();
+
+    /**
+     * 设置预设数量
+     */
+    void setPresetCount(int count);
+
+    /**
+     * 写入寄存器
+     * @param address 地址
+     * @param value 值
+     */
+    void writeRegister(int address, int value);
+
+    /**
+     * 读取寄存器
+     * @param address 地址
+     * @return 值
+     */
+    int readRegister(int address);
+
+    /**
+     * 读取寄存器(严格模式:未连接或读失败抛异常)
+     * 用于回读校验,区分「读到0」和「读失败」
+     */
+    default int readRegisterStrict(int address) throws Exception {
+        return readRegister(address);
+    }
+
+    /**
+     * 写入并回读校验:回读不一致再写一次,仍不一致返回 false
+     */
+    default boolean writeAndVerify(int address, int value) {
+        for (int i = 0; i < 2; i++) {
+            writeRegister(address, value);
+            try {
+                if (readRegisterStrict(address) == value) {
+                    return true;
+                }
+            } catch (Exception ignored) {
+                // 读失败按不一致处理
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 获取驱动类型
+     */
+    String getDriverType();
+
+    // ========== 新增方法 ==========
+
+    /**
+     * 读取位(布尔值)
+     * @param address 地址(S7格式如 DB1.2.2 或 Modbus格式如 15001)
+     * @return 位值
+     */
+    default boolean readBit(String address) {
+        return false;
+    }
+
+    /**
+     * 写入位(布尔值)
+     * @param address 地址
+     * @param value 值
+     */
+    default void writeBit(String address, boolean value) {
+    }
+
+    /**
+     * 读取整数
+     * @param address 地址
+     * @return 整数值
+     */
+    default int readInt(String address) {
+        return 0;
+    }
+
+    /**
+     * 写入整数
+     * @param address 地址
+     * @param value 值
+     */
+    default void writeInt(String address, int value) {
+    }
+
+    /**
+     * 检查设备是否已启动(读取启动信号)
+     */
+    default boolean isDeviceStarted() {
+        return false;
+    }
+
+    /**
+     * 发送启动命令
+     */
+    default void sendStartCommand() {
+    }
+
+    /**
+     * 发送停止命令
+     */
+    default void sendStopCommand() {
+    }
+
+    /**
+     * 重置设备状态(清零计数等)
+     */
+    default void resetDevice() {
+    }
+
+    /**
+     * 读取32位整数寄存器
+     * @param address 地址
+     * @return 值
+     */
+    default int readInt32(int address) {
+        return 0;
+    }
+}

+ 218 - 0
src/com/mes/device/RelayDriver.java

@@ -0,0 +1,218 @@
+package com.mes.device;
+
+import com.fazecast.jSerialComm.SerialPort;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigInteger;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 继电器驱动(Modbus RTU串口通信)
+ * 用于控制继电器开关
+ */
+public class RelayDriver implements IDeviceDriver {
+    private static final Logger log = LoggerFactory.getLogger(RelayDriver.class);
+
+    private SerialPort serialPort;
+    private String portName = "COM2";
+    private int baudrate = 38400;
+    private String openCommand = "010600000000";   // 开继电器命令
+    private String closeCommand = "010600000001";  // 关继电器命令
+
+    private volatile boolean connected = false;
+
+    public RelayDriver() {
+    }
+
+    public RelayDriver(String portName, int baudrate) {
+        this.portName = portName;
+        this.baudrate = baudrate;
+    }
+
+    @Override
+    public void connect(Map<String, Object> config) {
+        if (config != null) {
+            if (config.containsKey("port")) {
+                this.portName = config.get("port").toString();
+            }
+            if (config.containsKey("baudrate")) {
+                this.baudrate = ((Number) config.get("baudrate")).intValue();
+            }
+            if (config.containsKey("open_command")) {
+                this.openCommand = config.get("open_command").toString();
+            }
+            if (config.containsKey("close_command")) {
+                this.closeCommand = config.get("close_command").toString();
+            }
+        }
+
+        try {
+            serialPort = SerialPort.getCommPort(portName);
+            serialPort.setComPortParameters(baudrate, 8, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY);
+            serialPort.setComPortTimeouts(SerialPort.TIMEOUT_READ_BLOCKING, 1000, 0);
+
+            if (serialPort.openPort()) {
+                connected = true;
+                log.info("继电器串口{}打开成功", portName);
+            } else {
+                connected = false;
+                log.error("继电器串口{}打开失败", portName);
+            }
+        } catch (Exception e) {
+            log.error("继电器连接失败: {}", e.getMessage());
+            connected = false;
+        }
+    }
+
+    @Override
+    public void disconnect() {
+        if (serialPort != null && serialPort.isOpen()) {
+            serialPort.closePort();
+            log.info("继电器串口已关闭");
+        }
+        connected = false;
+    }
+
+    @Override
+    public boolean isConnected() {
+        return connected && serialPort != null && serialPort.isOpen();
+    }
+
+    /**
+     * 开继电器
+     */
+    public void open() {
+        sendCommand(openCommand);
+        log.info("继电器已开启");
+    }
+
+    /**
+     * 关继电器
+     */
+    public void close() {
+        sendCommand(closeCommand);
+        log.info("继电器已关闭");
+    }
+
+    /**
+     * 发送命令
+     */
+    private void sendCommand(String command) {
+        if (!isConnected()) {
+            reconnect();
+        }
+        if (!isConnected()) {
+            log.error("继电器未连接,无法发送命令");
+            return;
+        }
+
+        try {
+            String crc = getCRC16ModbusStr(command);
+            byte[] writeBuffer = hexStringToByteArray(command + crc);
+            serialPort.writeBytes(writeBuffer, writeBuffer.length);
+
+            // 读取响应
+            byte[] readBuffer = new byte[512];
+            int numRead = serialPort.readBytes(readBuffer, readBuffer.length);
+            log.debug("继电器响应: {} bytes", numRead);
+        } catch (Exception e) {
+            log.error("发送继电器命令失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 重新连接
+     */
+    private void reconnect() {
+        disconnect();
+        Map<String, Object> config = new HashMap<>();
+        config.put("port", portName);
+        config.put("baudrate", baudrate);
+        connect(config);
+    }
+
+    /**
+     * CRC16 Modbus校验
+     */
+    private String getCRC16ModbusStr(String data) {
+        data = data.replace(" ", "");
+        int len = data.length();
+        if (len % 2 != 0) {
+            return "0000";
+        }
+        int num = len / 2;
+        byte[] para = new byte[num];
+        for (int i = 0; i < num; i++) {
+            int value = Integer.valueOf(data.substring(i * 2, 2 * (i + 1)), 16);
+            para[i] = (byte) value;
+        }
+        return getCRC(para);
+    }
+
+    private String getCRC(byte[] bytes) {
+        int CRC = 0x0000ffff;
+        int POLYNOMIAL = 0x0000a001;
+        for (byte b : bytes) {
+            CRC ^= ((int) b & 0x000000ff);
+            for (int j = 0; j < 8; j++) {
+                if ((CRC & 0x00000001) != 0) {
+                    CRC >>= 1;
+                    CRC ^= POLYNOMIAL;
+                } else {
+                    CRC >>= 1;
+                }
+            }
+        }
+        String result = Integer.toHexString(CRC).toUpperCase();
+        if (result.length() != 4) {
+            StringBuilder sb = new StringBuilder("0000");
+            result = sb.replace(4 - result.length(), 4, result).toString();
+        }
+        return result.substring(2, 4) + result.substring(0, 2);
+    }
+
+    private byte[] hexStringToByteArray(String hexString) {
+        hexString = hexString.trim();
+        if (hexString.length() % 2 != 0) {
+            throw new IllegalArgumentException("Invalid hex string");
+        }
+        BigInteger bigInt = new BigInteger(hexString, 16);
+        byte[] byteArray = bigInt.toByteArray();
+        if (byteArray.length > hexString.length() / 2) {
+            byte[] trimmedArray = new byte[hexString.length() / 2];
+            System.arraycopy(byteArray, 1, trimmedArray, 0, trimmedArray.length);
+            return trimmedArray;
+        }
+        return byteArray;
+    }
+
+    // 以下方法继电器驱动不支持
+    @Override public void signalReady() { }
+    @Override public void startHeartbeat() { }
+    @Override public void stopHeartbeat() { }
+    @Override public Map<String, Object> readStatus() { return new HashMap<>(); }
+    @Override public boolean isWorkComplete() { return false; }
+    @Override public int getPresetCount() { return 0; }
+    @Override public int getFinishedCount() { return 0; }
+    @Override public int getQualifiedCount() { return 0; }
+    @Override public void setPresetCount(int count) { }
+    @Override public void writeRegister(int address, int value) { }
+    @Override public int readRegister(int address) { return 0; }
+
+    @Override
+    public String getDriverType() {
+        return "relay";
+    }
+
+    // Getters & Setters
+    public String getPortName() { return portName; }
+    public void setPortName(String portName) { this.portName = portName; }
+    public int getBaudrate() { return baudrate; }
+    public void setBaudrate(int baudrate) { this.baudrate = baudrate; }
+    public String getOpenCommand() { return openCommand; }
+    public void setOpenCommand(String openCommand) { this.openCommand = openCommand; }
+    public String getCloseCommand() { return closeCommand; }
+    public void setCloseCommand(String closeCommand) { this.closeCommand = closeCommand; }
+}

+ 81 - 179
src/com/mes/ui/MesClient.java

@@ -3,6 +3,7 @@ package com.mes.ui;
 import com.alibaba.fastjson2.JSONObject;
 import com.mes.component.MesRadio;
 import com.mes.component.MesWebView;
+import com.mes.component.StationPanel;
 import com.mes.util.DateLocalUtils;
 import com.mes.util.HttpUtils;
 import com.mes.util.JdbcUtils;
@@ -19,6 +20,8 @@ import java.io.*;
 import java.sql.ResultSet;
 import java.sql.Statement;
 import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.Properties;
 import java.util.Timer;
@@ -53,12 +56,12 @@ public class MesClient extends JFrame {
     public static JButton heart_beat_menu;
     public static JButton status_menu;
     public static JButton user_menu;
+    public static JButton plc_menu;
 
-    public static int scan_type = 0;
-    public static JButton finish_ok_bt;
-    public static JButton finish_ng_bt;
-    public static JTextField product_sn;
-    public static JButton f_scan_data_bt_1;
+    // 氦检 A/B 双工位
+    public static StationPanel stationA;
+    public static StationPanel stationB;
+    public static JTextArea logArea;
 
     public static String user20 = "";
 
@@ -74,8 +77,6 @@ public class MesClient extends JFrame {
     public static Object[] columnNames = { "物料名称", "绑定批次", "剩余次数", "操作" };
     public static Object[][] rowData = null;
 
-    public static JLabel fxlabel;
-
     public static void main(String[] args) {
 
         if (LockUtil.getInstance().isAppActive() == true){
@@ -185,14 +186,13 @@ public class MesClient extends JFrame {
     public static void resetScanA() {
         work_status = 0;
         check_quality_result = false;
-        MesClient.finish_ok_bt.setEnabled(false);
-        MesClient.finish_ng_bt.setEnabled(false);
-        product_sn.setText("");
-        MesClient.fxlabel.setVisible(false);
-
-        MesClient.f_scan_data_bt_1.setEnabled(true);
-        MesClient.setMenuStatus("请扫工件码",0);
-//        MesClient.setMenuStatus("开班点检,请先进行OKNG样件测试",-1);
+        if (stationA != null) {
+            stationA.reset();
+        }
+        if (stationB != null) {
+            stationB.reset();
+        }
+        MesClient.setMenuStatus("等待 PLC 扫码信号",0);
 
         updateMaterailData();
 //        shiftUserCheck();
@@ -249,64 +249,6 @@ public class MesClient extends JFrame {
         return barcodeRet;
     }
 
-    public static void scanBarcode() {
-        if(work_status == 1){
-            JOptionPane.showMessageDialog(mesClientFrame,"工作中,勿扫码","提示窗口", JOptionPane.INFORMATION_MESSAGE);
-            return;
-        }
-        String scanBarcodeTitle = "";
-        switch(scan_type) {
-            case 1:
-                product_sn.setText("");
-                scanBarcodeTitle = "请扫工件码";
-                break;
-        }
-
-        //弹窗扫工件码
-        String scanBarcode = JOptionPane.showInputDialog(null, scanBarcodeTitle);
-        if(scanBarcode!=null&&!scanBarcode.equalsIgnoreCase("")) {
-            //获取用户
-            getUser();
-            //获取扫码内容36位
-            String barcode36 = getBarcode(scanBarcode);//处理36为码
-            //工位号
-            String gw = "";
-            switch(scan_type) {
-                case 1:
-                    product_sn.setText(scanBarcode);
-                    break;
-            }
-            //刷新界面
-            mesClientFrame.repaint();
-
-            // 查询工件质量
-            JSONObject retObj = DataUtil.checkQuality(scanBarcode,user20);
-            if(retObj.get("result")!=null&&retObj.get("result").toString().equalsIgnoreCase("true")) {
-                MesClient.status_menu.setForeground(Color.GREEN);
-                MesClient.check_quality_result = true;//质量合格,可以绑定加工
-                MesClient.status_menu.setText("该工件可以加工");
-                MesClient.work_status = 1;
-                MesClient.f_scan_data_bt_1.setEnabled(false);
-                MesClient.finish_ok_bt.setEnabled(true);
-                MesClient.finish_ng_bt.setEnabled(true);
-            }else{
-                MesClient.check_quality_result = false;
-                MesClient.work_status = 0;
-                if(retObj.get("result")==null){
-                    MesClient.setMenuStatus("请求失败,请重试",-1);
-                }else{
-                    if(retObj.get("result").toString().equalsIgnoreCase("false")){
-                        MesClient.setMenuStatus(retObj.getString("message"),-1);
-                    }
-                }
-            }
-        }else {
-            MesClient.setMenuStatus("请扫工件码,请重试",-1);
-//            JOptionPane.showMessageDialog(mesClientFrame,"请扫工件码","提示窗口", JOptionPane.INFORMATION_MESSAGE);
-            return;
-        }
-    }
-
     public static void logoff() {
         welcomeWin.setVisible(true);
         mesClientFrame.setVisible(false);
@@ -330,6 +272,12 @@ public class MesClient extends JFrame {
                     System.exit(0);
                 }
             }
+
+            @Override
+            public void windowOpened(WindowEvent e) {
+                // 窗口显示后再写日志,避免与首次布局争抢锁
+                addLog("界面已加载,等待 PLC 连接");
+            }
         });
 
         JMenuBar menuBar = new JMenuBar();
@@ -445,8 +393,17 @@ public class MesClient extends JFrame {
         JLabel space_3 = new JLabel("    ");
         toolBar.add(space_3);
 
-        JLabel space_4 = new JLabel("    ");
-        toolBar.add(space_4);
+        JLabel plc_status_label = new JLabel("PLC:");
+        plc_status_label.setHorizontalAlignment(SwingConstants.CENTER);
+        plc_status_label.setForeground(Color.BLACK);
+        plc_status_label.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        toolBar.add(plc_status_label);
+
+        plc_menu = new JButton("未连接");
+        plc_menu.setForeground(Color.RED);
+        plc_menu.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        plc_menu.setBackground(Color.BLACK);
+        toolBar.add(plc_menu);
 
         tabbedPane = new JTabbedPane(JTabbedPane.TOP);
         tabbedPane.setMinimumSize(new Dimension(400, 50));
@@ -456,116 +413,32 @@ public class MesClient extends JFrame {
         //首页
         JPanel indexPanelA = new JPanel();
         indexScrollPaneA = new JScrollPane(indexPanelA);
+        indexScrollPaneA.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_NEVER);
+        indexScrollPaneA.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
         indexPanelA.setLayout(null);
 
-        product_sn = new JTextField();
-        product_sn.setHorizontalAlignment(SwingConstants.CENTER);
-        product_sn.setEditable(false);
-        product_sn.setFont(new Font("微软雅黑", Font.PLAIN, 28));
-        product_sn.setBounds(81, 70, 602, 70);
-        indexPanelA.add(product_sn);
-        product_sn.setColumns(10);
+        // 氦检双工位面板:A/B 并排卡片 + 底部日志
+        stationA = new StationPanel("A 工位");
+        stationA.setBounds(10, 10, 460, 370);
+        indexPanelA.add(stationA);
 
-        f_scan_data_bt_1 = new JButton("扫码");
-        f_scan_data_bt_1.addActionListener(new ActionListener() {
-            public void actionPerformed(ActionEvent e) {
-                scan_type = 1;
-                scanBarcode();
-            }
-        });
-        f_scan_data_bt_1.setIcon(new ImageIcon(MesClient.class.getResource("/bg/scan_barcode.png")));
-        f_scan_data_bt_1.setFont(new Font("微软雅黑", Font.PLAIN, 32));
-        f_scan_data_bt_1.setBounds(693, 70, 198, 70);
-        indexPanelA.add(f_scan_data_bt_1);
-
-//        String[] hjtitles = new String[]{"焊机1","焊机2","焊机3"};
-//        String[] hjvals = new String[]{"HJ001","HJ002","HJ003"};
-//        mesRadioHj = new MesRadio(hjtitles,hjvals);
-//        mesRadioHj.setSize(500,50);
-//        mesRadioHj.setBounds(190,170,500,50);
-//        indexPanelA.add(mesRadioHj);
-
-        fxlabel = new JLabel("该工件为返修件,请仔细检查");
-        fxlabel.setFont(new Font("微软雅黑", Font.PLAIN, 38));
-        fxlabel.setBounds(81, 170, 810, 70);
-        fxlabel.setForeground(Color.RED);
-        fxlabel.setHorizontalAlignment(SwingConstants.CENTER);
-        fxlabel.setVisible(false);
-        indexPanelA.add(fxlabel);
-
-        finish_ok_bt = new JButton("OK");
-        finish_ok_bt.setEnabled(false);
-        finish_ok_bt.addActionListener(new ActionListener() {
-            public void actionPerformed(ActionEvent e) {
-                if(work_status == 1 && check_quality_result){
-                    getUser();
-                    String sn = MesClient.product_sn.getText().trim();
-                    if(sn.isEmpty()){
-                        MesClient.setMenuStatus("工件码为空,请重试",-1);
-                        return;
-                    }
+        stationB = new StationPanel("B 工位");
+        stationB.setBounds(485, 10, 460, 370);
+        indexPanelA.add(stationB);
 
-                    JSONObject retObj = DataUtil.sendQuality(sn,"OK",user20);
-                    if(retObj.get("result")!=null&&retObj.get("result").toString().equalsIgnoreCase("true")) {
-                        MesClient.resetScanA();
-                        MesClient.setMenuStatus("结果提交成功,请扫下一件",0);
-                        MesClient.scan_type = 1;
-                        MesClient.scanBarcode();
-                    }else{
-                        if(retObj.get("result")==null){
-                            MesClient.setMenuStatus("请求失败,请重试",-1);
-                        }else{
-                            if(retObj.get("result").toString().equalsIgnoreCase("false")){
-                                MesClient.setMenuStatus(retObj.getString("message"),-1);
-                            }
-                        }
-                    }
-                }
-            }
-        });
-        finish_ok_bt.setIcon(new ImageIcon(MesClient.class.getResource("/bg/ok_bg.png")));
-        finish_ok_bt.setFont(new Font("微软雅黑", Font.PLAIN, 32));
-        finish_ok_bt.setBounds(185, 291, 240, 80);
-        finish_ok_bt.setEnabled(false);
-        indexPanelA.add(finish_ok_bt);
-
-        finish_ng_bt = new JButton("NG");
-        finish_ng_bt.setEnabled(false);
-        finish_ng_bt.addActionListener(new ActionListener() {
-            public void actionPerformed(ActionEvent e) {
-                if(work_status == 1 && check_quality_result){
-                    getUser();
-                    String sn = MesClient.product_sn.getText().trim();
-                    if(sn.isEmpty()){
-                        MesClient.setMenuStatus("工件码为空,请重试",-1);
-                        return;
-                    }
+        logArea = new JTextArea();
+        logArea.setEditable(false);
+        logArea.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        logArea.setBackground(Color.WHITE);
+        logArea.setForeground(Color.BLACK);
+        logArea.setMargin(new Insets(6, 8, 6, 8));
+        JScrollPane logScrollPane = new JScrollPane(logArea);
+        logScrollPane.setBounds(10, 390, 935, 150);
+        indexPanelA.add(logScrollPane);
 
-                    JSONObject retObj = DataUtil.sendQuality(sn,"NG",user20);
-                    if(retObj.get("result")!=null&&retObj.get("result").toString().equalsIgnoreCase("true")) {
-                        MesClient.resetScanA();
-                        MesClient.setMenuStatus("结果提交成功,请扫下一件",0);
-                        MesClient.scan_type = 1;
-                        MesClient.scanBarcode();
-                    }else{
-                        if(retObj.get("result")==null){
-                            MesClient.setMenuStatus("请求失败,请重试",-1);
-                        }else{
-                            if(retObj.get("result").toString().equalsIgnoreCase("false")){
-                                MesClient.setMenuStatus(retObj.getString("message"),-1);
-                            }
-                        }
-                    }
-                }
-            }
-        });
-        finish_ng_bt.setIcon(new ImageIcon(MesClient.class.getResource("/bg/ng_bg.png")));
-        finish_ng_bt.setFont(new Font("微软雅黑", Font.PLAIN, 32));
-        finish_ng_bt.setBounds(508, 291, 240, 80);
-        finish_ng_bt.setEnabled(false);
-        indexPanelA.add(finish_ng_bt);
+        indexPanelA.setPreferredSize(new Dimension(955, 550));
 
-        tabbedPane.addTab("工作面板", new ImageIcon(MesClient.class.getResource("/bg/a_side.png")), indexScrollPaneA, null);
+        tabbedPane.addTab("检测面板", new ImageIcon(MesClient.class.getResource("/bg/a_side.png")), indexScrollPaneA, null);
         tabbedPane.setEnabledAt(0, true);
 
 //		searchScrollPane = new JScrollPane((Component) null);
@@ -634,6 +507,35 @@ public class MesClient extends JFrame {
         MesClient.status_menu.setText(msg);
     }
 
+    // ===== 氦检工位 UI 更新(PLC 线程可直接调用,内部已切到 EDT) =====
+
+    /** 追加一条运行日志,自动滚到底 */
+    public static void addLog(String msg) {
+        if (logArea == null) {
+            return;
+        }
+        SwingUtilities.invokeLater(() -> {
+            logArea.append(LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "  " + msg + "\n");
+            logArea.setCaretPosition(logArea.getDocument().getLength());
+        });
+    }
+
+    /** PLC 连接状态 */
+    public static void setPlcStatus(boolean connected) {
+        if (plc_menu == null) {
+            return;
+        }
+        SwingUtilities.invokeLater(() -> {
+            plc_menu.setText(connected ? "已连接" : "未连接");
+            plc_menu.setForeground(connected ? Color.GREEN : Color.RED);
+        });
+    }
+
+    /** 取工位卡片:1=A,2=B */
+    public static StationPanel getStation(int station) {
+        return station == 2 ? stationB : stationA;
+    }
+
     public static void getMaterailData(){
         try{
             JSONObject retObj = DataUtil.getBindMaterail();

File diff suppressed because it is too large
+ 2656 - 0
src/com/mes/ui/component/WorkstationPanel.java


+ 436 - 0
src/com/mes/ui/layout/ComponentResizer.java

@@ -0,0 +1,436 @@
+package com.mes.ui.layout;
+
+import java.awt.*;
+import java.awt.event.*;
+import java.util.*;
+import javax.swing.JComponent;
+import javax.swing.SwingUtilities;
+
+/**
+ * The ComponentResizer allows you to resize a component by dragging a border
+ * of the component.
+ * 
+ * 来源: https://tips4java.wordpress.com/2009/09/13/resizing-components/
+ */
+public class ComponentResizer extends MouseAdapter {
+    private final static Dimension MINIMUM_SIZE = new Dimension(10, 10);
+    private final static Dimension MAXIMUM_SIZE =
+        new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE);
+
+    private static Map<Integer, Integer> cursors = new HashMap<Integer, Integer>();
+    {
+        cursors.put(1, Cursor.N_RESIZE_CURSOR);
+        cursors.put(2, Cursor.W_RESIZE_CURSOR);
+        cursors.put(4, Cursor.S_RESIZE_CURSOR);
+        cursors.put(8, Cursor.E_RESIZE_CURSOR);
+        cursors.put(3, Cursor.NW_RESIZE_CURSOR);
+        cursors.put(9, Cursor.NE_RESIZE_CURSOR);
+        cursors.put(6, Cursor.SW_RESIZE_CURSOR);
+        cursors.put(12, Cursor.SE_RESIZE_CURSOR);
+    }
+
+    private Insets dragInsets;
+    private Dimension snapSize;
+
+    private int direction;
+    protected static final int NORTH = 1;
+    protected static final int WEST = 2;
+    protected static final int SOUTH = 4;
+    protected static final int EAST = 8;
+
+    private Cursor sourceCursor;
+    private boolean resizing;
+    private Rectangle bounds;
+    private Point pressed;
+    private boolean autoscrolls;
+
+    private Dimension minimumSize = MINIMUM_SIZE;
+    private Dimension maximumSize = MAXIMUM_SIZE;
+    
+    // 性能优化:减少重绘频率
+    private long lastUpdateTime = 0;
+    private static final long UPDATE_INTERVAL_MS = 16; // 约60fps
+
+    /**
+     * Convenience contructor. All borders are resizable in increments of
+     * a single pixel. Components must be registered separately.
+     */
+    public ComponentResizer() {
+        this(new Insets(5, 5, 5, 5), new Dimension(1, 1));
+    }
+
+    /**
+     * Convenience contructor. All borders are resizable in increments of
+     * a single pixel. Components can be registered when the class is created
+     * or they can be registered separately afterwards.
+     *
+     * @param components components to be automatically registered
+     */
+    public ComponentResizer(Component... components) {
+        this(new Insets(5, 5, 5, 5), new Dimension(1, 1), components);
+    }
+
+    /**
+     * Convenience contructor. Eligible borders are resisable in increments of
+     * a single pixel. Components can be registered when the class is created
+     * or they can be registered separately afterwards.
+     *
+     * @param dragInsets Insets specifying which borders are eligible to be
+     *                   resized.
+     * @param components components to be automatically registered
+     */
+    public ComponentResizer(Insets dragInsets, Component... components) {
+        this(dragInsets, new Dimension(1, 1), components);
+    }
+
+    /**
+     * Create a ComponentResizer.
+     *
+     * @param dragInsets Insets specifying which borders are eligible to be
+     *                   resized.
+     * @param snapSize   Specify the dimension to which the border will snap to
+     *                   when being dragged. Snapping occurs at the halfway mark.
+     * @param components components to be automatically registered
+     */
+    public ComponentResizer(Insets dragInsets, Dimension snapSize, Component... components) {
+        setDragInsets(dragInsets);
+        setSnapSize(snapSize);
+        registerComponent(components);
+    }
+
+    /**
+     * Get the drag insets
+     *
+     * @return the drag insets
+     */
+    public Insets getDragInsets() {
+        return dragInsets;
+    }
+
+    /**
+     * Set the drag dragInsets. The insets specify an area where mouseDragged
+     * events are recognized from the edge of the border inwards. A value of
+     * 0 for any size will imply that the border is not resizable. Otherwise
+     * the appropriate drag cursor will appear when the mouse is inside the
+     * resizable border area.
+     *
+     * @param dragInsets Insets to control which borders are resizeable.
+     */
+    public void setDragInsets(Insets dragInsets) {
+        validateMinimumAndInsets(minimumSize, dragInsets);
+        this.dragInsets = dragInsets;
+    }
+
+    /**
+     * Get the components maximum size.
+     *
+     * @return the maximum size
+     */
+    public Dimension getMaximumSize() {
+        return maximumSize;
+    }
+
+    /**
+     * Specify the maximum size for the component. The component will still
+     * be constrained by the size of its parent.
+     *
+     * @param maximumSize the maximum size for a component.
+     */
+    public void setMaximumSize(Dimension maximumSize) {
+        this.maximumSize = maximumSize;
+    }
+
+    /**
+     * Get the components minimum size.
+     *
+     * @return the minimum size
+     */
+    public Dimension getMinimumSize() {
+        return minimumSize;
+    }
+
+    /**
+     * Specify the minimum size for the component. The minimum size is
+     * constrained by the drag insets.
+     *
+     * @param minimumSize the minimum size for a component.
+     */
+    public void setMinimumSize(Dimension minimumSize) {
+        validateMinimumAndInsets(minimumSize, dragInsets);
+        this.minimumSize = minimumSize;
+    }
+
+    /**
+     * Remove listeners from the specified component
+     *
+     * @param component the component the listeners are removed from
+     */
+    public void deregisterComponent(Component... components) {
+        for (Component component : components) {
+            component.removeMouseListener(this);
+            component.removeMouseMotionListener(this);
+        }
+    }
+
+    /**
+     * Add the required listeners to the specified component
+     *
+     * @param component the component the listeners are added to
+     */
+    public void registerComponent(Component... components) {
+        for (Component component : components) {
+            component.addMouseListener(this);
+            component.addMouseMotionListener(this);
+        }
+    }
+
+    /**
+     * Get the snap size.
+     *
+     * @return the snap size.
+     */
+    public Dimension getSnapSize() {
+        return snapSize;
+    }
+
+    /**
+     * Control how many pixels a border must be dragged before the size of
+     * the component is changed. The border will snap to the size once
+     * dragging has passed the halfway mark.
+     *
+     * @param snapSize Dimension object allows you to separately spcify a
+     *                 horizontal and vertical snap size.
+     */
+    public void setSnapSize(Dimension snapSize) {
+        this.snapSize = snapSize;
+    }
+
+    /**
+     * When the components minimum size is less than the drag insets then
+     * we can't determine which border should be resized so we need to
+     * prevent this from happening.
+     */
+    private void validateMinimumAndInsets(Dimension minimum, Insets drag) {
+        int minimumWidth = drag.left + drag.right;
+        int minimumHeight = drag.top + drag.bottom;
+
+        if (minimum.width < minimumWidth
+                || minimum.height < minimumHeight) {
+            String message = "Minimum size cannot be less than drag insets";
+            throw new IllegalArgumentException(message);
+        }
+    }
+
+    @Override
+    public void mouseMoved(MouseEvent e) {
+        Component source = e.getComponent();
+        Point location = e.getPoint();
+        direction = 0;
+
+        if (location.x < dragInsets.left)
+            direction += WEST;
+
+        if (location.x > source.getWidth() - dragInsets.right - 1)
+            direction += EAST;
+
+        if (location.y < dragInsets.top)
+            direction += NORTH;
+
+        if (location.y > source.getHeight() - dragInsets.bottom - 1)
+            direction += SOUTH;
+
+        // Mouse is no longer over a resizable border
+        if (direction == 0) {
+            source.setCursor(sourceCursor);
+        } else { // use the appropriate resizable cursor
+            int cursorType = cursors.get(direction);
+            Cursor cursor = Cursor.getPredefinedCursor(cursorType);
+            source.setCursor(cursor);
+        }
+    }
+
+    @Override
+    public void mouseEntered(MouseEvent e) {
+        if (!resizing) {
+            Component source = e.getComponent();
+            sourceCursor = source.getCursor();
+        }
+    }
+
+    @Override
+    public void mouseExited(MouseEvent e) {
+        if (!resizing) {
+            Component source = e.getComponent();
+            source.setCursor(sourceCursor);
+        }
+    }
+
+    @Override
+    public void mousePressed(MouseEvent e) {
+        // The mouseMoved event continually updates this variable
+        if (direction == 0)
+            return;
+
+        // Setup for resizing. All future dragging calculations are done based
+        // on the original bounds of the component and mouse pressed location.
+        resizing = true;
+        lastUpdateTime = 0; // 重置时间,允许立即更新
+
+        Component source = e.getComponent();
+        pressed = e.getPoint();
+        SwingUtilities.convertPointToScreen(pressed, source);
+        bounds = source.getBounds();
+
+        // Making sure autoscrolls is false will allow for smoother resizing
+        // of components
+        if (source instanceof JComponent) {
+            JComponent jc = (JComponent) source;
+            autoscrolls = jc.getAutoscrolls();
+            jc.setAutoscrolls(false);
+        }
+    }
+
+    /**
+     * Restore the original state of the Component
+     */
+    @Override
+    public void mouseReleased(MouseEvent e) {
+        resizing = false;
+
+        Component source = e.getComponent();
+        source.setCursor(sourceCursor);
+
+        if (source instanceof JComponent) {
+            ((JComponent) source).setAutoscrolls(autoscrolls);
+        }
+        
+        // 通知DraggableComponentWrapper调整大小完成
+        if (source instanceof DraggableComponentWrapper) {
+            ((DraggableComponentWrapper) source).onResizeComplete();
+        }
+    }
+
+    /**
+     * Resize the component ensuring location and size is within the bounds
+     * of the parent container and that the size is within the minimum and
+     * maximum constraints.
+     *
+     * All calculations are done using the bounds of the component when the
+     * resizing started.
+     */
+    @Override
+    public void mouseDragged(MouseEvent e) {
+        if (resizing == false)
+            return;
+
+        // 性能优化:限制更新频率,避免过度重绘
+        long currentTime = System.currentTimeMillis();
+        if (currentTime - lastUpdateTime < UPDATE_INTERVAL_MS) {
+            return;
+        }
+        lastUpdateTime = currentTime;
+
+        Component source = e.getComponent();
+        Point dragged = e.getPoint();
+        SwingUtilities.convertPointToScreen(dragged, source);
+
+        changeBounds(source, direction, bounds, pressed, dragged);
+        
+        // 通知DraggableComponentWrapper更新配置
+        if (source instanceof DraggableComponentWrapper) {
+            ((DraggableComponentWrapper) source).onResizing();
+        }
+    }
+
+    protected void changeBounds(Component source, int direction, Rectangle bounds, Point pressed, Point current) {
+        // Start with original locaton and size
+        int x = bounds.x;
+        int y = bounds.y;
+        int width = bounds.width;
+        int height = bounds.height;
+
+        // Resizing the West or North border affects the size and location
+        if (WEST == (direction & WEST)) {
+            int drag = getDragDistance(pressed.x, current.x, snapSize.width);
+            int maximum = Math.min(width + x, maximumSize.width);
+            drag = getDragBounded(drag, snapSize.width, width, minimumSize.width, maximum);
+
+            x -= drag;
+            width += drag;
+        }
+
+        if (NORTH == (direction & NORTH)) {
+            int drag = getDragDistance(pressed.y, current.y, snapSize.height);
+            int maximum = Math.min(height + y, maximumSize.height);
+            drag = getDragBounded(drag, snapSize.height, height, minimumSize.height, maximum);
+
+            y -= drag;
+            height += drag;
+        }
+
+        // Resizing the East or South border only affects the size
+        if (EAST == (direction & EAST)) {
+            int drag = getDragDistance(current.x, pressed.x, snapSize.width);
+            Dimension boundingSize = getBoundingSize(source);
+            int maximum = Math.min(boundingSize.width - x, maximumSize.width);
+            drag = getDragBounded(drag, snapSize.width, width, minimumSize.width, maximum);
+            width += drag;
+        }
+
+        if (SOUTH == (direction & SOUTH)) {
+            int drag = getDragDistance(current.y, pressed.y, snapSize.height);
+            Dimension boundingSize = getBoundingSize(source);
+            int maximum = Math.min(boundingSize.height - y, maximumSize.height);
+            drag = getDragBounded(drag, snapSize.height, height, minimumSize.height, maximum);
+            height += drag;
+        }
+
+        // 使用单次setBounds调用,避免多次重绘
+        source.setBounds(x, y, width, height);
+        
+        // 使用revalidate()代替validate()以提高性能
+        if (source instanceof JComponent) {
+            ((JComponent) source).revalidate();
+        } else {
+            source.validate();
+        }
+    }
+
+    /*
+     * Determine how far the mouse has moved from where dragging started
+     */
+    private int getDragDistance(int larger, int smaller, int snapSize) {
+        int halfway = snapSize / 2;
+        int drag = larger - smaller;
+        drag += (drag < 0) ? -halfway : halfway;
+        drag = (drag / snapSize) * snapSize;
+
+        return drag;
+    }
+
+    /*
+     * Adjust the drag value to be within the minimum and maximum range.
+     */
+    private int getDragBounded(int drag, int snapSize, int dimension, int minimum, int maximum) {
+        while (dimension + drag < minimum)
+            drag += snapSize;
+
+        while (dimension + drag > maximum)
+            drag -= snapSize;
+
+        return drag;
+    }
+
+    /*
+     * Keep the size of the component within the bounds of its parent.
+     */
+    private Dimension getBoundingSize(Component source) {
+        if (source instanceof Window) {
+            GraphicsEnvironment env = GraphicsEnvironment.getLocalGraphicsEnvironment();
+            Rectangle bounds = env.getMaximumWindowBounds();
+            return new Dimension(bounds.width, bounds.height);
+        } else {
+            return source.getParent().getSize();
+        }
+    }
+}

+ 593 - 0
src/com/mes/ui/layout/DraggableComponentWrapper.java

@@ -0,0 +1,593 @@
+package com.mes.ui.layout;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.swing.*;
+import javax.swing.border.LineBorder;
+import java.awt.*;
+import java.awt.event.*;
+
+/**
+ * 可拖拽组件包装器
+ * 在编辑模式下包装原始组件,添加拖拽、调整大小功能
+ * 使用成熟的ComponentResizer处理调整大小
+ */
+public class DraggableComponentWrapper extends JPanel {
+    private static final Logger log = LoggerFactory.getLogger(DraggableComponentWrapper.class);
+    
+    // 原始组件
+    private JComponent wrappedComponent;
+    
+    // 组件ID
+    private String componentId;
+    
+    // 布局配置引用
+    private UILayoutConfig.ComponentLayout layoutConfig;
+    
+    // 父容器(用于计算相对位置)
+    private Container parentContainer;
+    
+    // 拖拽状态
+    private boolean editMode = false;
+    private boolean dragging = false;
+    private boolean resizing = false;  // 标记是否正在调整大小
+    private Point dragStartPoint;
+    private Point componentStartPoint;
+    
+    // 调整大小处理器
+    private ComponentResizer resizer;
+    
+    // 保存监听器引用以便移除
+    private MouseAdapter dragListener;
+    
+    // 网格吸附
+    private boolean snapToGrid = true;
+    private int gridSize = 10;
+    
+    // 状态监听器
+    private LayoutChangeListener layoutChangeListener;
+    
+    // 性能优化:限制更新频率
+    private long lastDragUpdateTime = 0;
+    private static final long DRAG_UPDATE_INTERVAL_MS = 16; // 约60fps
+    
+    public DraggableComponentWrapper(JComponent component, String componentId, 
+                                     UILayoutConfig.ComponentLayout layoutConfig,
+                                     Container parentContainer) {
+        this.wrappedComponent = component;
+        this.componentId = componentId;
+        this.layoutConfig = layoutConfig;
+        this.parentContainer = parentContainer;
+        
+        initUI();
+        setupListeners();
+    }
+    
+    /**
+     * 初始化UI
+     */
+    private void initUI() {
+        setLayout(new BorderLayout());
+        setOpaque(false);
+        add(wrappedComponent, BorderLayout.CENTER);
+        
+        // 创建调整大小处理器
+        resizer = new ComponentResizer();
+        resizer.setMinimumSize(new Dimension(50, 30));
+        resizer.setDragInsets(new Insets(15, 15, 15, 15));  // 15像素边缘
+        resizer.setSnapSize(new Dimension(gridSize, gridSize));
+        
+        // 编辑模式下显示边框
+        updateBorder();
+    }
+    
+    /**
+     * 设置编辑模式
+     */
+    public void setEditMode(boolean editMode) {
+        this.editMode = editMode;
+        updateBorder();
+        
+        if (editMode) {
+            // 编辑模式:禁用子组件以防止点击触发功能
+            // 但保持可见状态
+            if (wrappedComponent instanceof JButton) {
+                // 禁用按钮,防止触发ActionListener
+                wrappedComponent.setEnabled(false);
+            }
+            wrappedComponent.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR));
+            setCursor(Cursor.getDefaultCursor());
+        } else {
+            // 恢复子组件状态
+            wrappedComponent.setEnabled(layoutConfig.isEnabled());
+            wrappedComponent.setCursor(Cursor.getDefaultCursor());
+            
+            // 移除监听器
+            if (dragListener != null) {
+                this.removeMouseListener(dragListener);
+                this.removeMouseMotionListener(dragListener);
+                wrappedComponent.removeMouseListener(dragListener);
+                wrappedComponent.removeMouseMotionListener(dragListener);
+            }
+            
+            // 禁用调整大小
+            if (resizer != null) {
+                resizer.deregisterComponent(this);
+            }
+            setCursor(Cursor.getDefaultCursor());
+            
+            // 重置拖拽状态
+            dragging = false;
+            resizing = false;
+        }
+    }
+    
+    /**
+     * 更新边框显示
+     */
+    private void updateBorder() {
+        if (editMode) {
+            setBorder(new LineBorder(new Color(66, 165, 245), 2, false));
+            setOpaque(true);
+            setBackground(new Color(66, 165, 245, 30));  // 半透明蓝色
+        } else {
+            setBorder(null);
+            setOpaque(false);
+        }
+    }
+    
+    /**
+     * 设置监听器
+     */
+    private void setupListeners() {
+        // 拖拽移动监听器
+        dragListener = new MouseAdapter() {
+            @Override
+            public void mousePressed(MouseEvent e) {
+                if (!editMode) return;
+                
+                // 编辑模式下消费事件,防止按钮触发点击
+                e.consume();
+                
+                // 中心区域点击:启动拖拽移动
+                dragging = true;
+                resizing = false;
+                lastDragUpdateTime = 0;
+                
+                componentStartPoint = getLocation();
+                dragStartPoint = e.getLocationOnScreen();
+            }
+            
+            @Override
+            public void mouseReleased(MouseEvent e) {
+                if (!editMode) return;
+                
+                // 编辑模式下消费事件
+                e.consume();
+                
+                if (dragging) {
+                    updateLayoutConfig();
+                    
+                    if (layoutChangeListener != null) {
+                        layoutChangeListener.onLayoutChanged(componentId, layoutConfig);
+                    }
+                }
+                
+                dragging = false;
+                resizing = false;
+            }
+            
+            @Override
+            public void mouseClicked(MouseEvent e) {
+                if (!editMode) return;
+                // 编辑模式下消费点击事件,防止按钮触发
+                e.consume();
+            }
+            
+            @Override
+            public void mouseDragged(MouseEvent e) {
+                if (!editMode) return;
+                
+                // 编辑模式下消费事件
+                e.consume();
+                
+                if (dragging && !resizing) {
+                    handleDrag(e);
+                }
+            }
+            
+            @Override
+            public void mouseMoved(MouseEvent e) {
+                if (!editMode) return;
+                
+                // 子组件区域显示移动光标
+                wrappedComponent.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR));
+            }
+        };
+        
+        // 在包装器上添加监听器(编辑模式下子组件被禁用,事件会冒泡到包装器)
+        this.addMouseListener(dragListener);
+        this.addMouseMotionListener(dragListener);
+        
+        // 同时在子组件上添加监听器(以防万一)
+        wrappedComponent.addMouseListener(dragListener);
+        wrappedComponent.addMouseMotionListener(dragListener);
+        
+        // 在包装器上注册ComponentResizer(处理边缘区域的调整大小)
+        resizer.registerComponent(this);
+        
+        // 右键菜单
+        setComponentPopupMenu(createPopupMenu());
+    }
+    
+    /**
+     * 处理拖拽移动
+     */
+    private void handleDrag(MouseEvent e) {
+        try {
+            // 性能优化:限制更新频率
+            long currentTime = System.currentTimeMillis();
+            if (currentTime - lastDragUpdateTime < DRAG_UPDATE_INTERVAL_MS) {
+                return;
+            }
+            lastDragUpdateTime = currentTime;
+            
+            Point currentPoint = e.getLocationOnScreen();
+            int dx = currentPoint.x - dragStartPoint.x;
+            int dy = currentPoint.y - dragStartPoint.y;
+            
+            int newX = componentStartPoint.x + dx;
+            int newY = componentStartPoint.y + dy;
+            
+            // 网格吸附
+            if (snapToGrid) {
+                newX = Math.round(newX / (float)gridSize) * gridSize;
+                newY = Math.round(newY / (float)gridSize) * gridSize;
+            }
+            
+            // 边界检查(使用实际父组件的大小)
+            Container actualParent = getParent();
+            if (actualParent == null) {
+                actualParent = parentContainer;
+            }
+            if (actualParent != null) {
+                int maxX = actualParent.getWidth() - getWidth();
+                int maxY = actualParent.getHeight() - getHeight();
+                newX = Math.max(0, Math.min(newX, maxX));
+                newY = Math.max(0, Math.min(newY, maxY));
+            }
+            
+            // 只移动位置,不改变大小
+            setLocation(newX, newY);
+            
+        } catch (Exception ex) {
+            log.error("拖拽处理出错", ex);
+        }
+    }
+    
+    /**
+     * 更新布局配置(将像素坐标转换为相对坐标)
+     */
+    private void updateLayoutConfig() {
+        // 使用实际父组件(gridOverlay)的大小
+        Container actualParent = getParent();
+        if (actualParent == null) {
+            actualParent = parentContainer;
+        }
+        if (actualParent == null) return;
+        
+        int parentWidth = actualParent.getWidth();
+        int parentHeight = actualParent.getHeight();
+        
+        if (parentWidth == 0 || parentHeight == 0) return;
+        
+        // 从 WorkstationPanel 获取布局偏移量
+        int offsetLeft = 10;
+        int offsetTop = 40;
+        int offsetRight = 10;
+        int offsetBottom = 10;
+        
+        // 尝试从 parentContainer 获取正确的偏移量
+        if (parentContainer instanceof com.mes.ui.component.WorkstationPanel) {
+            int[] offsets = ((com.mes.ui.component.WorkstationPanel) parentContainer).getLayoutOffsets();
+            offsetLeft = offsets[0];
+            offsetTop = offsets[1];
+            offsetRight = offsets[2];
+            offsetBottom = offsets[3];
+        }
+        
+        // 计算模拟的内容区域大小
+        int contentWidth = parentWidth - offsetLeft - offsetRight;
+        int contentHeight = parentHeight - offsetTop - offsetBottom;
+        
+        if (contentWidth <= 0 || contentHeight <= 0) return;
+        
+        // 计算相对位置 (0-1),减去偏移量后再计算
+        double relX = (double)(getX() - offsetLeft) / contentWidth;
+        double relY = (double)(getY() - offsetTop) / contentHeight;
+        double relWidth = (double) getWidth() / contentWidth;
+        double relHeight = (double) getHeight() / contentHeight;
+        
+        // 确保值在合理范围内
+        relX = Math.max(0, Math.min(1, relX));
+        relY = Math.max(0, Math.min(1, relY));
+        relWidth = Math.max(0.01, Math.min(1, relWidth));
+        relHeight = Math.max(0.01, Math.min(1, relHeight));
+        
+        // 更新配置
+        layoutConfig.getPosition().setX(relX);
+        layoutConfig.getPosition().setY(relY);
+        layoutConfig.getSize().setWidth(relWidth);
+        layoutConfig.getSize().setHeight(relHeight);
+    }
+    
+    /**
+     * 创建右键菜单
+     */
+    private JPopupMenu createPopupMenu() {
+        JPopupMenu menu = new JPopupMenu();
+        
+        // 对齐选项
+        JMenu alignMenu = new JMenu("对齐");
+        alignMenu.add(createMenuItem("左对齐", e -> alignLeft()));
+        alignMenu.add(createMenuItem("水平居中", e -> alignCenter()));
+        alignMenu.add(createMenuItem("右对齐", e -> alignRight()));
+        alignMenu.addSeparator();
+        alignMenu.add(createMenuItem("顶部对齐", e -> alignTop()));
+        alignMenu.add(createMenuItem("垂直居中", e -> alignMiddle()));
+        alignMenu.add(createMenuItem("底部对齐", e -> alignBottom()));
+        menu.add(alignMenu);
+        
+        menu.addSeparator();
+        
+        // 尺寸选项
+        menu.add(createMenuItem("适应内容", e -> fitToContent()));
+        menu.add(createMenuItem("重置尺寸", e -> resetSize()));
+        
+        menu.addSeparator();
+        
+        // 显示/隐藏
+        JCheckBoxMenuItem visibleItem = new JCheckBoxMenuItem("显示", layoutConfig.isVisible());
+        visibleItem.addActionListener(e -> {
+            layoutConfig.setVisible(visibleItem.isSelected());
+            setVisible(visibleItem.isSelected());
+        });
+        menu.add(visibleItem);
+        
+        // 启用/禁用
+        JCheckBoxMenuItem enabledItem = new JCheckBoxMenuItem("启用", layoutConfig.isEnabled());
+        enabledItem.addActionListener(e -> {
+            layoutConfig.setEnabled(enabledItem.isSelected());
+            wrappedComponent.setEnabled(enabledItem.isSelected());
+        });
+        menu.add(enabledItem);
+        
+        menu.addSeparator();
+        
+        // 属性编辑
+        menu.add(createMenuItem("编辑属性...", e -> showPropertiesDialog()));
+        
+        return menu;
+    }
+    
+    private JMenuItem createMenuItem(String text, ActionListener listener) {
+        JMenuItem item = new JMenuItem(text);
+        item.addActionListener(listener);
+        return item;
+    }
+    
+    // 获取实际父容器
+    private Container getActualParent() {
+        Container actualParent = getParent();
+        return actualParent != null ? actualParent : parentContainer;
+    }
+    
+    // 对齐方法
+    private void alignLeft() {
+        setLocation(0, getY());
+        updateLayoutConfig();
+    }
+    
+    private void alignCenter() {
+        Container parent = getActualParent();
+        if (parent != null) {
+            int centerX = (parent.getWidth() - getWidth()) / 2;
+            setLocation(centerX, getY());
+            updateLayoutConfig();
+        }
+    }
+    
+    private void alignRight() {
+        Container parent = getActualParent();
+        if (parent != null) {
+            int rightX = parent.getWidth() - getWidth();
+            setLocation(rightX, getY());
+            updateLayoutConfig();
+        }
+    }
+    
+    private void alignTop() {
+        setLocation(getX(), 0);
+        updateLayoutConfig();
+    }
+    
+    private void alignMiddle() {
+        Container parent = getActualParent();
+        if (parent != null) {
+            int middleY = (parent.getHeight() - getHeight()) / 2;
+            setLocation(getX(), middleY);
+            updateLayoutConfig();
+        }
+    }
+    
+    private void alignBottom() {
+        Container parent = getActualParent();
+        if (parent != null) {
+            int bottomY = parent.getHeight() - getHeight();
+            setLocation(getX(), bottomY);
+            updateLayoutConfig();
+        }
+    }
+    
+    private void fitToContent() {
+        Dimension prefSize = wrappedComponent.getPreferredSize();
+        setSize(prefSize);
+        updateLayoutConfig();
+    }
+    
+    private void resetSize() {
+        Container parent = getActualParent();
+        if (parent != null) {
+            // 恢复默认尺寸
+            int defaultWidth = (int)(layoutConfig.getSize().getWidth() * parent.getWidth());
+            int defaultHeight = (int)(layoutConfig.getSize().getHeight() * parent.getHeight());
+            setSize(defaultWidth, defaultHeight);
+            updateLayoutConfig();
+        }
+    }
+    
+    /**
+     * 显示属性编辑对话框
+     */
+    private void showPropertiesDialog() {
+        JDialog dialog = new JDialog((Frame)SwingUtilities.getWindowAncestor(this), 
+                                     "组件属性 - " + componentId, true);
+        dialog.setSize(450, 400);
+        dialog.setLocationRelativeTo(this);
+        
+        // 创建属性编辑面板
+        JPanel panel = new JPanel(new GridLayout(0, 2, 10, 10));
+        panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+        
+        // 字体大小
+        panel.add(new JLabel("字体大小:"));
+        JSpinner fontSizeSpinner = new JSpinner(new SpinnerNumberModel(
+            layoutConfig.getStyle().getFontSize(), 8, 72, 1));
+        panel.add(fontSizeSpinner);
+        
+        // 前景色
+        panel.add(new JLabel("文字颜色:"));
+        JButton fgColorBtn = new JButton("选择颜色");
+        fgColorBtn.addActionListener(e -> {
+            Color color = JColorChooser.showDialog(dialog, "选择文字颜色", 
+                Color.decode(layoutConfig.getStyle().getForeground()));
+            if (color != null) {
+                layoutConfig.getStyle().setForeground(String.format("#%06X", 
+                    color.getRGB() & 0xFFFFFF));
+                wrappedComponent.setForeground(color);
+            }
+        });
+        panel.add(fgColorBtn);
+        
+        // 背景色
+        panel.add(new JLabel("背景颜色:"));
+        JButton bgColorBtn = new JButton("选择颜色");
+        bgColorBtn.addActionListener(e -> {
+            Color color = JColorChooser.showDialog(dialog, "选择背景颜色",
+                Color.decode(layoutConfig.getStyle().getBackground()));
+            if (color != null) {
+                layoutConfig.getStyle().setBackground(String.format("#%06X", 
+                    color.getRGB() & 0xFFFFFF));
+                wrappedComponent.setBackground(color);
+            }
+        });
+        panel.add(bgColorBtn);
+        
+        // 确定取消按钮
+        JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 10, 10));
+        
+        JButton okBtn = new JButton("确定");
+        okBtn.addActionListener(e -> {
+            // 应用更改
+            int fontSize = (int)fontSizeSpinner.getValue();
+            layoutConfig.getStyle().setFontSize(fontSize);
+            Font font = wrappedComponent.getFont();
+            wrappedComponent.setFont(font.deriveFont((float)fontSize));
+            
+            if (layoutChangeListener != null) {
+                layoutChangeListener.onLayoutChanged(componentId, layoutConfig);
+            }
+            
+            dialog.dispose();
+        });
+        btnPanel.add(okBtn);
+        
+        JButton cancelBtn = new JButton("取消");
+        cancelBtn.addActionListener(e -> dialog.dispose());
+        btnPanel.add(cancelBtn);
+        
+        dialog.setLayout(new BorderLayout());
+        dialog.add(panel, BorderLayout.CENTER);
+        dialog.add(btnPanel, BorderLayout.SOUTH);
+        dialog.setVisible(true);
+    }
+    
+    // Getters & Setters
+    
+    public JComponent getWrappedComponent() {
+        return wrappedComponent;
+    }
+    
+    public String getComponentId() {
+        return componentId;
+    }
+    
+    public UILayoutConfig.ComponentLayout getLayoutConfig() {
+        return layoutConfig;
+    }
+    
+    public void setLayoutConfig(UILayoutConfig.ComponentLayout layoutConfig) {
+        this.layoutConfig = layoutConfig;
+    }
+    
+    public void setSnapToGrid(boolean snapToGrid) {
+        this.snapToGrid = snapToGrid;
+        if (resizer != null) {
+            resizer.setSnapSize(new Dimension(gridSize, gridSize));
+        }
+    }
+    
+    public void setGridSize(int gridSize) {
+        this.gridSize = gridSize;
+        if (resizer != null) {
+            resizer.setSnapSize(new Dimension(gridSize, gridSize));
+        }
+    }
+    
+    public void setLayoutChangeListener(LayoutChangeListener listener) {
+        this.layoutChangeListener = listener;
+    }
+    
+    /**
+     * 调整大小中(由ComponentResizer调用)
+     */
+    public void onResizing() {
+        resizing = true;
+        dragging = false;  // 确保不会同时拖拽和调整大小
+    }
+    
+    /**
+     * 调整大小完成(由ComponentResizer调用)
+     */
+    public void onResizeComplete() {
+        // 直接保存配置,不检查 resizing 标志
+        // 因为 ComponentResizer 只会在真正调整大小完成时调用此方法
+        updateLayoutConfig();
+        
+        // 触发布局变更事件
+        if (layoutChangeListener != null) {
+            layoutChangeListener.onLayoutChanged(componentId, layoutConfig);
+        }
+        
+        log.debug("调整大小完成: {}, 新尺寸: {}x{}", componentId, getWidth(), getHeight());
+        resizing = false;
+    }
+    
+    /**
+     * 布局变更监听器
+     */
+    public interface LayoutChangeListener {
+        void onLayoutChanged(String componentId, UILayoutConfig.ComponentLayout layout);
+    }
+}

+ 554 - 0
src/com/mes/ui/layout/UILayoutConfig.java

@@ -0,0 +1,554 @@
+package com.mes.ui.layout;
+
+import org.yaml.snakeyaml.DumperOptions;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.Constructor;
+import org.yaml.snakeyaml.representer.Representer;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * UI布局配置 - 保存组件位置、大小、样式
+ * 支持两种布局模式:free(自由定位) 和 flex(弹性行布局)
+ */
+public class UILayoutConfig {
+    // 布局版本
+    private String version = "1.0";
+    
+    // 布局模式: "free" = 自由定位(默认), "flex" = 弹性行布局
+    private String layoutMode = "free";
+    
+    // 组件布局配置 (free模式使用)
+    private Map<String, ComponentLayout> components = new LinkedHashMap<>();
+    
+    // 单工位模式布局
+    private Map<String, ComponentLayout> singleComponents = new LinkedHashMap<>();
+    
+    // 双工位模式布局
+    private Map<String, ComponentLayout> dualComponents = new LinkedHashMap<>();
+    
+    // 行布局配置 (flex模式使用)
+    private List<RowLayout> rows = new ArrayList<>();
+    
+    // 单工位模式行布局
+    private List<RowLayout> singleRows = new ArrayList<>();
+    
+    // 双工位模式行布局
+    private List<RowLayout> dualRows = new ArrayList<>();
+    
+    // 全局设置
+    private GlobalSettings globalSettings = new GlobalSettings();
+    
+    /**
+     * 行布局配置 (flex模式)
+     */
+    public static class RowLayout {
+        private String id;
+        private int order = 0;
+        private double flex = 1.0;
+        private boolean visible = true;
+        private int gap = 10;
+        private String alignItems = "stretch";
+        private List<RowComponentLayout> components = new ArrayList<>();
+        
+        public String getId() { return id; }
+        public void setId(String id) { this.id = id; }
+        public int getOrder() { return order; }
+        public void setOrder(int order) { this.order = order; }
+        public double getFlex() { return flex; }
+        public void setFlex(double flex) { this.flex = flex; }
+        public boolean isVisible() { return visible; }
+        public void setVisible(boolean visible) { this.visible = visible; }
+        public int getGap() { return gap; }
+        public void setGap(int gap) { this.gap = gap; }
+        public String getAlignItems() { return alignItems; }
+        public void setAlignItems(String alignItems) { this.alignItems = alignItems; }
+        public List<RowComponentLayout> getComponents() { return components; }
+        public void setComponents(List<RowComponentLayout> components) { this.components = components; }
+        
+        public List<RowComponentLayout> getVisibleComponents() {
+            List<RowComponentLayout> visibleList = new ArrayList<>();
+            for (RowComponentLayout comp : components) {
+                if (comp.isVisible()) visibleList.add(comp);
+            }
+            return visibleList;
+        }
+    }
+    
+    /**
+     * 行内组件布局配置
+     */
+    public static class RowComponentLayout {
+        private String id;
+        private double flex = 1.0;
+        private int fixedWidth = 0;
+        private int minWidth = 50;
+        private boolean visible = true;
+        private boolean enabled = true;
+        private Style style;
+        
+        public String getId() { return id; }
+        public void setId(String id) { this.id = id; }
+        public double getFlex() { return flex; }
+        public void setFlex(double flex) { this.flex = flex; }
+        public int getFixedWidth() { return fixedWidth; }
+        public void setFixedWidth(int fixedWidth) { this.fixedWidth = fixedWidth; }
+        public int getMinWidth() { return minWidth; }
+        public void setMinWidth(int minWidth) { this.minWidth = minWidth; }
+        public boolean isVisible() { return visible; }
+        public void setVisible(boolean visible) { this.visible = visible; }
+        public boolean isEnabled() { return enabled; }
+        public void setEnabled(boolean enabled) { this.enabled = enabled; }
+        public Style getStyle() { return style; }
+        public void setStyle(Style style) { this.style = style; }
+    }
+    
+    /**
+     * 组件布局配置
+     */
+    public static class ComponentLayout {
+        // 组件ID (唯一标识)
+        private String id;
+        
+        // 组件类型 (text_field, button, panel等)
+        private String type;
+        
+        // 显示名称
+        private String displayName;
+        
+        // 是否可见
+        private boolean visible = true;
+        
+        // 是否启用
+        private boolean enabled = true;
+        
+        // 位置 (相对坐标 0-1)
+        private Position position = new Position();
+        
+        // 尺寸 (相对尺寸 0-1)
+        private Size size = new Size();
+        
+        // 样式
+        private Style style = new Style();
+        
+        // 约束 (对齐、锚点等)
+        private Constraints constraints = new Constraints();
+        
+        // 自定义属性
+        private Map<String, Object> properties = new HashMap<>();
+
+        // Getters & Setters
+        public String getId() { return id; }
+        public void setId(String id) { this.id = id; }
+        
+        public String getType() { return type; }
+        public void setType(String type) { this.type = type; }
+        
+        public String getDisplayName() { return displayName; }
+        public void setDisplayName(String displayName) { this.displayName = displayName; }
+        
+        public boolean isVisible() { return visible; }
+        public void setVisible(boolean visible) { this.visible = visible; }
+        
+        public boolean isEnabled() { return enabled; }
+        public void setEnabled(boolean enabled) { this.enabled = enabled; }
+        
+        public Position getPosition() { return position; }
+        public void setPosition(Position position) { this.position = position; }
+        
+        public Size getSize() { return size; }
+        public void setSize(Size size) { this.size = size; }
+        
+        public Style getStyle() { return style; }
+        public void setStyle(Style style) { this.style = style; }
+        
+        public Constraints getConstraints() { return constraints; }
+        public void setConstraints(Constraints constraints) { this.constraints = constraints; }
+        
+        public Map<String, Object> getProperties() { return properties; }
+        public void setProperties(Map<String, Object> properties) { this.properties = properties; }
+    }
+    
+    /**
+     * 位置 (相对坐标)
+     */
+    public static class Position {
+        private double x = 0.0;  // 0-1, 相对于父容器宽度
+        private double y = 0.0;  // 0-1, 相对于父容器高度
+        private String anchor = "top_left";  // 锚点: top_left, center, bottom_right等
+
+        public double getX() { return x; }
+        public void setX(double x) { this.x = x; }
+        
+        public double getY() { return y; }
+        public void setY(double y) { this.y = y; }
+        
+        public String getAnchor() { return anchor; }
+        public void setAnchor(String anchor) { this.anchor = anchor; }
+    }
+    
+    /**
+     * 尺寸 (相对尺寸)
+     */
+    public static class Size {
+        private double width = 0.5;   // 0-1, 相对于父容器宽度
+        private double height = 0.1;  // 0-1, 相对于父容器高度
+        private int minWidth = 100;   // 最小宽度(像素)
+        private int minHeight = 30;   // 最小高度(像素)
+        private int maxWidth = 2000;  // 最大宽度(像素)
+        private int maxHeight = 500;  // 最大高度(像素)
+        private boolean autoSize = false;  // 自适应内容大小
+
+        public double getWidth() { return width; }
+        public void setWidth(double width) { this.width = width; }
+        
+        public double getHeight() { return height; }
+        public void setHeight(double height) { this.height = height; }
+        
+        public int getMinWidth() { return minWidth; }
+        public void setMinWidth(int minWidth) { this.minWidth = minWidth; }
+        
+        public int getMinHeight() { return minHeight; }
+        public void setMinHeight(int minHeight) { this.minHeight = minHeight; }
+        
+        public int getMaxWidth() { return maxWidth; }
+        public void setMaxWidth(int maxWidth) { this.maxWidth = maxWidth; }
+        
+        public int getMaxHeight() { return maxHeight; }
+        public void setMaxHeight(int maxHeight) { this.maxHeight = maxHeight; }
+        
+        public boolean isAutoSize() { return autoSize; }
+        public void setAutoSize(boolean autoSize) { this.autoSize = autoSize; }
+    }
+    
+    /**
+     * 样式
+     */
+    public static class Style {
+        private String fontName = "微软雅黑";
+        private int fontSize = 20;
+        private int fontStyle = 0;  // 0=PLAIN, 1=BOLD, 2=ITALIC
+        private String foreground = "#000000";  // 前景色(文字颜色)
+        private String background = "#FFFFFF";  // 背景色
+        private int borderWidth = 1;
+        private String borderColor = "#CCCCCC";
+        private int borderRadius = 0;  // 圆角半径
+        private int[] padding = {5, 5, 5, 5};  // 内边距 [top, right, bottom, left]
+        private int[] margin = {0, 0, 0, 0};   // 外边距
+
+        public String getFontName() { return fontName; }
+        public void setFontName(String fontName) { this.fontName = fontName; }
+        
+        public int getFontSize() { return fontSize; }
+        public void setFontSize(int fontSize) { this.fontSize = fontSize; }
+        
+        public int getFontStyle() { return fontStyle; }
+        public void setFontStyle(int fontStyle) { this.fontStyle = fontStyle; }
+        
+        public String getForeground() { return foreground; }
+        public void setForeground(String foreground) { this.foreground = foreground; }
+        
+        public String getBackground() { return background; }
+        public void setBackground(String background) { this.background = background; }
+        
+        public int getBorderWidth() { return borderWidth; }
+        public void setBorderWidth(int borderWidth) { this.borderWidth = borderWidth; }
+        
+        public String getBorderColor() { return borderColor; }
+        public void setBorderColor(String borderColor) { this.borderColor = borderColor; }
+        
+        public int getBorderRadius() { return borderRadius; }
+        public void setBorderRadius(int borderRadius) { this.borderRadius = borderRadius; }
+        
+        public int[] getPadding() { return padding; }
+        public void setPadding(int[] padding) { this.padding = padding; }
+        
+        public int[] getMargin() { return margin; }
+        public void setMargin(int[] margin) { this.margin = margin; }
+    }
+    
+    /**
+     * 约束条件
+     */
+    public static class Constraints {
+        private String alignX = "left";    // left, center, right
+        private String alignY = "top";     // top, center, bottom
+        private boolean snapToGrid = true;  // 吸附到网格
+        private int gridSize = 10;         // 网格大小(像素)
+        private String relativeTo = null;  // 相对于哪个组件定位
+        private int offsetX = 0;           // X偏移量
+        private int offsetY = 0;           // Y偏移量
+        private int zIndex = 0;            // 层级(越大越在上层)
+
+        public String getAlignX() { return alignX; }
+        public void setAlignX(String alignX) { this.alignX = alignX; }
+        
+        public String getAlignY() { return alignY; }
+        public void setAlignY(String alignY) { this.alignY = alignY; }
+        
+        public boolean isSnapToGrid() { return snapToGrid; }
+        public void setSnapToGrid(boolean snapToGrid) { this.snapToGrid = snapToGrid; }
+        
+        public int getGridSize() { return gridSize; }
+        public void setGridSize(int gridSize) { this.gridSize = gridSize; }
+        
+        public String getRelativeTo() { return relativeTo; }
+        public void setRelativeTo(String relativeTo) { this.relativeTo = relativeTo; }
+        
+        public int getOffsetX() { return offsetX; }
+        public void setOffsetX(int offsetX) { this.offsetX = offsetX; }
+        
+        public int getOffsetY() { return offsetY; }
+        public void setOffsetY(int offsetY) { this.offsetY = offsetY; }
+        
+        public int getZIndex() { return zIndex; }
+        public void setZIndex(int zIndex) { this.zIndex = zIndex; }
+    }
+    
+    /**
+     * 全局设置
+     */
+    public static class GlobalSettings {
+        private int baseWidth = 800;   // 基准宽度
+        private int baseHeight = 600;  // 基准高度
+        private boolean showGrid = false;  // 显示网格
+        private int gridSize = 20;     // 网格大小
+        private String gridColor = "#EEEEEE";
+        private boolean enableSnap = true;  // 启用吸附
+        private int snapThreshold = 5;  // 吸附阈值(像素)
+
+        public int getBaseWidth() { return baseWidth; }
+        public void setBaseWidth(int baseWidth) { this.baseWidth = baseWidth; }
+        
+        public int getBaseHeight() { return baseHeight; }
+        public void setBaseHeight(int baseHeight) { this.baseHeight = baseHeight; }
+        
+        public boolean isShowGrid() { return showGrid; }
+        public void setShowGrid(boolean showGrid) { this.showGrid = showGrid; }
+        
+        public int getGridSize() { return gridSize; }
+        public void setGridSize(int gridSize) { this.gridSize = gridSize; }
+        
+        public String getGridColor() { return gridColor; }
+        public void setGridColor(String gridColor) { this.gridColor = gridColor; }
+        
+        public boolean isEnableSnap() { return enableSnap; }
+        public void setEnableSnap(boolean enableSnap) { this.enableSnap = enableSnap; }
+        
+        public int getSnapThreshold() { return snapThreshold; }
+        public void setSnapThreshold(int snapThreshold) { this.snapThreshold = snapThreshold; }
+    }
+    
+    // Getters & Setters
+    public String getVersion() { return version; }
+    public void setVersion(String version) { this.version = version; }
+    
+    public String getLayoutMode() { return layoutMode; }
+    public void setLayoutMode(String layoutMode) { this.layoutMode = layoutMode; }
+    
+    public Map<String, ComponentLayout> getComponents() { return components; }
+    public void setComponents(Map<String, ComponentLayout> components) { this.components = components; }
+    
+    public Map<String, ComponentLayout> getSingleComponents() { return singleComponents; }
+    public void setSingleComponents(Map<String, ComponentLayout> singleComponents) { this.singleComponents = singleComponents; }
+    
+    public Map<String, ComponentLayout> getDualComponents() { return dualComponents; }
+    public void setDualComponents(Map<String, ComponentLayout> dualComponents) { this.dualComponents = dualComponents; }
+    
+    /**
+     * 根据模式获取组件配置
+     * @param isSingleMode true=单工位模式, false=双工位模式
+     */
+    public Map<String, ComponentLayout> getComponentsForMode(boolean isSingleMode) {
+        Map<String, ComponentLayout> modeComponents = isSingleMode ? singleComponents : dualComponents;
+        // 如果模式专用配置为空,回退到通用配置
+        return (modeComponents != null && !modeComponents.isEmpty()) ? modeComponents : components;
+    }
+    
+    /**
+     * 根据模式获取单个组件配置
+     */
+    public ComponentLayout getComponentForMode(String id, boolean isSingleMode) {
+        Map<String, ComponentLayout> modeComponents = getComponentsForMode(isSingleMode);
+        return modeComponents.get(id);
+    }
+    
+    /**
+     * 根据模式设置组件配置
+     */
+    public void setComponentForMode(String id, ComponentLayout layout, boolean isSingleMode) {
+        if (isSingleMode) {
+            singleComponents.put(id, layout);
+        } else {
+            dualComponents.put(id, layout);
+        }
+    }
+    
+    public List<RowLayout> getRows() { return rows; }
+    public void setRows(List<RowLayout> rows) { this.rows = rows; }
+    
+    public List<RowLayout> getSingleRows() { return singleRows; }
+    public void setSingleRows(List<RowLayout> singleRows) { this.singleRows = singleRows; }
+    
+    public List<RowLayout> getDualRows() { return dualRows; }
+    public void setDualRows(List<RowLayout> dualRows) { this.dualRows = dualRows; }
+    
+    /**
+     * 根据模式获取行配置
+     * @param isSingleMode true=单工位模式, false=双工位模式
+     */
+    public List<RowLayout> getRowsForMode(boolean isSingleMode) {
+        List<RowLayout> modeRows = isSingleMode ? singleRows : dualRows;
+        // 如果模式专用配置为空,回退到通用配置
+        return (modeRows != null && !modeRows.isEmpty()) ? modeRows : rows;
+    }
+    
+    /**
+     * 获取可见的行列表(按order排序)
+     */
+    public List<RowLayout> getVisibleRowsForMode(boolean isSingleMode) {
+        List<RowLayout> allRows = getRowsForMode(isSingleMode);
+        List<RowLayout> visibleRows = new ArrayList<>();
+        for (RowLayout row : allRows) {
+            if (row.isVisible()) {
+                visibleRows.add(row);
+            }
+        }
+        visibleRows.sort((a, b) -> Integer.compare(a.getOrder(), b.getOrder()));
+        return visibleRows;
+    }
+    
+    /**
+     * 设置行可见性(指定模式)
+     */
+    public void setRowVisibleForMode(String rowId, boolean visible, boolean isSingleMode) {
+        List<RowLayout> modeRows = getRowsForMode(isSingleMode);
+        for (RowLayout row : modeRows) {
+            if (row.getId() != null && row.getId().equals(rowId)) {
+                row.setVisible(visible);
+                return;
+            }
+        }
+    }
+    
+    public GlobalSettings getGlobalSettings() { return globalSettings; }
+    public void setGlobalSettings(GlobalSettings globalSettings) { this.globalSettings = globalSettings; }
+    
+    /**
+     * 是否为弹性布局模式
+     */
+    public boolean isFlexMode() {
+        return "flex".equalsIgnoreCase(layoutMode);
+    }
+    
+    /**
+     * 添加组件布局 (free模式)
+     */
+    public void addComponent(String id, ComponentLayout layout) {
+        components.put(id, layout);
+    }
+    
+    /**
+     * 获取组件布局 (free模式)
+     */
+    public ComponentLayout getComponent(String id) {
+        return components.get(id);
+    }
+    
+    /**
+     * 移除组件 (free模式)
+     */
+    public void removeComponent(String id) {
+        components.remove(id);
+    }
+    
+    /**
+     * 获取所有组件 (free模式)
+     */
+    public Collection<ComponentLayout> getAllComponents() {
+        return components.values();
+    }
+    
+    /**
+     * 获取行 (flex模式)
+     */
+    public RowLayout getRow(String id) {
+        for (RowLayout row : rows) {
+            if (row.getId() != null && row.getId().equals(id)) {
+                return row;
+            }
+        }
+        return null;
+    }
+    
+    /**
+     * 获取可见的行列表 (flex模式)
+     */
+    public List<RowLayout> getVisibleRows() {
+        List<RowLayout> visibleRows = new ArrayList<>();
+        for (RowLayout row : rows) {
+            if (row.isVisible()) {
+                visibleRows.add(row);
+            }
+        }
+        visibleRows.sort((a, b) -> Integer.compare(a.getOrder(), b.getOrder()));
+        return visibleRows;
+    }
+    
+    /**
+     * 设置行可见性 (flex模式)
+     */
+    public void setRowVisible(String rowId, boolean visible) {
+        RowLayout row = getRow(rowId);
+        if (row != null) {
+            row.setVisible(visible);
+        }
+    }
+    
+    /**
+     * 转换为YAML(不带类型标签)
+     */
+    public String toYaml() {
+        DumperOptions options = new DumperOptions();
+        options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
+        options.setPrettyFlow(true);
+        options.setIndent(2);
+        options.setIndicatorIndent(0);
+        
+        // 使用Representer跳过类型标签
+        Representer representer = new Representer(options);
+        representer.getPropertyUtils().setSkipMissingProperties(true);
+        
+        Yaml yaml = new Yaml(representer, options);
+        return yaml.dump(this);
+    }
+    
+    /**
+     * 从YAML加载(支持带/不带类型标签)
+     */
+    public static UILayoutConfig fromYaml(String yamlContent) {
+        // 移除可能存在的类型标签
+        String cleanYaml = yamlContent;
+        if (cleanYaml.startsWith("!!")) {
+            int newLineIndex = cleanYaml.indexOf('\n');
+            if (newLineIndex > 0) {
+                cleanYaml = cleanYaml.substring(newLineIndex + 1);
+            }
+        }
+        
+        // 配置加载选项,允许全局标签
+        LoaderOptions loaderOptions = new LoaderOptions();
+        loaderOptions.setAllowDuplicateKeys(false);
+        
+        Constructor constructor = new Constructor(UILayoutConfig.class, loaderOptions);
+        Yaml yaml = new Yaml(constructor);
+        
+        return yaml.load(cleanYaml);
+    }
+}

BIN
~$交互流程(1).doc


BIN
氦检交互流程(1).doc