Selaa lähdekoodia

项目切换(P1/P2/P3/P4),前缀与工位自动跟随
工位号可选,支持A-L后缀
拉铆设备IP软件内修改,自动重连
工艺流程可勾选组合,物料扫码联动显示
设备信息显示可配置
菜单重组,支持密码或快捷免密解锁
排查并修复拉铆心跳超时问题

wangxichen 4 viikkoa sitten
vanhempi
commit
af968deb66

+ 0 - 1
.gitignore

@@ -7,7 +7,6 @@ target/
 *.iml
 
 # 运行时配置
-config/
 
 # 日志
 *.log

+ 17 - 0
config/debug_rules.json

@@ -0,0 +1,17 @@
+{
+	"stations":{
+		"OP060A":{
+			"enabled":true,
+			"rules":{
+				
+			}
+		},
+		"OP050A":{
+			"enabled":false,
+			"rules":{
+				
+			}
+		}
+	},
+	"lastModified":1770366595539
+}

+ 19 - 0
config/project_config.json

@@ -0,0 +1,19 @@
+{
+	"currentProjectId":"P3",
+	"deviceIp":"192.168.0.6",
+	"stationCodeOverride":"OP220A",
+	"lineSnOverride":"XT",
+	"uiOverride":{
+		"deviceRow1Enabled":true,
+		"deviceRow1Label":"M5",
+		"deviceRow2Enabled":false,
+		"showMaterialInput":false
+	},
+	"featuresOverride":{
+		"cold_plate":false,
+		"riveting":true,
+		"prod_params":true,
+		"bottom_plate":false
+	},
+	"lastModified":1778225043639
+}

+ 26 - 0
src/com/mes/App.java

@@ -26,6 +26,30 @@ public class App {
         StationConfig config;
         try {
             config = StationConfig.getInstance();
+            // 应用项目配置:将运行时保存的设备IP覆盖到所有设备连接
+            com.mes.core.ProjectConfigManager projectConfig = com.mes.core.ProjectConfigManager.getInstance();
+            // 工位号/线体覆盖(单工位场景仅覆盖第一个)
+            String lineOverride = projectConfig.getLineSnOverride();
+            if (lineOverride != null && !lineOverride.isEmpty()) {
+                log.info("应用线体覆盖: {} -> {}", config.getLineSn(), lineOverride);
+                config.setLineSn(lineOverride);
+            }
+            String codeOverride = projectConfig.getStationCodeOverride();
+            if (codeOverride != null && !codeOverride.isEmpty() && !config.getStations().isEmpty()) {
+                StationConfig.StationInfo first = config.getStations().get(0);
+                log.info("应用工位号覆盖: {} -> {}", first.getCode(), codeOverride);
+                first.setCode(codeOverride);
+            }
+            String runtimeIp = projectConfig.getDeviceIp();
+            if (runtimeIp != null && !runtimeIp.isEmpty()) {
+                for (StationConfig.DeviceConnection conn : config.getDeviceConnections()) {
+                    if (!runtimeIp.equals(conn.getIp())) {
+                        log.info("应用项目配置IP覆盖: station_index={}, {} -> {}",
+                                conn.getStationIndex(), conn.getIp(), runtimeIp);
+                        conn.setIp(runtimeIp);
+                    }
+                }
+            }
             log.info("配置加载成功:");
             log.info("  - 模式: {}", config.getMode());
             log.info("  - 工位数: {}", config.getStationCount());
@@ -35,6 +59,8 @@ public class App {
             }
             log.info("  - 服务器: {}:{}", config.getServerIp(), config.getTcpPort());
             log.info("  - 设备: {} ({})", config.isDeviceEnabled() ? "启用" : "禁用", config.getDeviceType());
+            log.info("  - 当前项目: {} ({})", projectConfig.getCurrentProjectId(),
+                    projectConfig.getCurrentProject() != null ? projectConfig.getCurrentProject().getName() : "?");
         } catch (Exception e) {
             log.error("配置加载失败: {}", e.getMessage(), e);
             JOptionPane.showMessageDialog(null,

+ 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 projectId, String code) {
+        if (projectId == null || code == null) return "";
+        Map<String, String> table = tableOf(projectId);
+        if (table == null) return "";
+        return table.getOrDefault(formatOprno(code), "");
+    }
+
+    /** 获取指定项目的工位码列表(UI下拉用) */
+    public static List<String> listCodes(String projectId) {
+        Map<String, String> table = tableOf(projectId);
+        if (table == null) return Collections.emptyList();
+        return new java.util.ArrayList<>(table.keySet());
+    }
+
+    private static Map<String, String> tableOf(String projectId) {
+        if (projectId == null) return null;
+        switch (projectId.toUpperCase()) {
+            case "P1":
+            case "P2":
+                return P1P2;
+            case "P3":
+                return P3;
+            case "P4":
+                return P4;
+            default:
+                return null;
+        }
+    }
+}

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

@@ -0,0 +1,419 @@
+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的运行时持久化
+ *
+ * 项目前缀对应表内置,避免误改;运行时可变的仅"当前项目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 Project {
+        private final String id;          // P1/P2/P3/P4
+        private final String name;        // 显示名(如 AY7-610)
+        private final String productPrefix;      // 工件码(框架码) 前缀
+        private final String coldPlatePrefix;    // 冷板码 前缀
+        private final String bottomPlatePrefix;  // 底护板码 前缀
+
+        public Project(String id, String name,
+                       String productPrefix, String coldPlatePrefix, String bottomPlatePrefix) {
+            this.id = id;
+            this.name = name;
+            this.productPrefix = productPrefix;
+            this.coldPlatePrefix = coldPlatePrefix;
+            this.bottomPlatePrefix = bottomPlatePrefix;
+        }
+
+        public String getId() { return id; }
+        public String getName() { return name; }
+        public String getProductPrefix() { return productPrefix; }
+        public String getColdPlatePrefix() { return coldPlatePrefix; }
+        public String getBottomPlatePrefix() { return bottomPlatePrefix; }
+
+        /** 供UI下拉显示 */
+        public String getDisplay() { return id + " " + name; }
+    }
+
+    // 内置项目列表(顺序即 UI 展示顺序)
+    private static final List<Project> PROJECTS = Collections.unmodifiableList(Arrays.asList(
+            new Project("P1", "P02-1",   "+KA99", "+KB004", "+KB77"),
+            new Project("P2", "AY7-610", "+KB24", "+KA94",  "+KB77"),
+            new Project("P3", "AY7-520", "+KB23", "+KB004", "+KB77"),
+            new Project("P4", "P04-1",   "+KA93", "+KA94",  "+KB77")
+    ));
+
+    // 默认值
+    private static final String DEFAULT_PROJECT_ID = "P2";
+    private static final String DEFAULT_DEVICE_IP = "192.168.0.6";
+
+    // ========== 单例 ==========
+    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 currentProjectId;
+    private String deviceIp;
+    private String stationCodeOverride;   // 覆盖 yaml 的 stations[0].code
+    private String lineSnOverride;        // 覆盖 yaml 的 line_sn
+    private final Map<String, Boolean> featuresOverride = new LinkedHashMap<>();
+    // 设备信息行覆盖:是否启用 + label(寄存器地址不在UI改)
+    private Boolean deviceRow1EnabledOverride;
+    private String  deviceRow1LabelOverride;
+    private Boolean deviceRow2EnabledOverride;
+    private String  deviceRow2LabelOverride;
+    // 物料码输入框显隐覆盖
+    private Boolean showMaterialInputOverride;
+    private final List<Listener> listeners = new ArrayList<>();
+
+    private ProjectConfigManager() {
+        this.configPath = Paths.get(CONFIG_DIR, CONFIG_FILE);
+        this.currentProjectId = DEFAULT_PROJECT_ID;
+        this.deviceIp = DEFAULT_DEVICE_IP;
+        load();
+    }
+
+    // ========== 加载/保存 ==========
+
+    private void load() {
+        if (!Files.exists(configPath)) {
+            log.info("[ProjectConfig] 配置文件不存在,使用默认: project={}, ip={}", currentProjectId, deviceIp);
+            return;
+        }
+        try {
+            String content = new String(Files.readAllBytes(configPath), StandardCharsets.UTF_8);
+            JSONObject json = JSON.parseObject(content);
+            if (json == null) return;
+
+            String pid = json.getString("currentProjectId");
+            if (pid != null && findProject(pid) != null) {
+                currentProjectId = pid;
+            }
+            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();
+            }
+            // 设备信息行
+            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");
+            }
+            log.info("[ProjectConfig] 加载成功: project={}, ip={}, station={}, lineSn={}, features={}",
+                    currentProjectId, deviceIp, stationCodeOverride, lineSnOverride, featuresOverride);
+        } 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("currentProjectId", currentProjectId);
+            root.put("deviceIp", deviceIp);
+            if (stationCodeOverride != null) root.put("stationCodeOverride", stationCodeOverride);
+            if (lineSnOverride != null) root.put("lineSnOverride", lineSnOverride);
+            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);
+            if (!uiObj.isEmpty()) root.put("uiOverride", uiObj);
+            if (!featuresOverride.isEmpty()) {
+                root.put("featuresOverride", featuresOverride);
+            }
+            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<Project> getProjects() {
+        return PROJECTS;
+    }
+
+    public Project findProject(String id) {
+        if (id == null) return null;
+        for (Project p : PROJECTS) {
+            if (p.getId().equalsIgnoreCase(id)) return p;
+        }
+        return null;
+    }
+
+    public Project getCurrentProject() {
+        Project p = findProject(currentProjectId);
+        return p != null ? p : PROJECTS.get(0);
+    }
+
+    public String getCurrentProjectId() {
+        return currentProjectId;
+    }
+
+    // ========== 前缀快捷方法 ==========
+
+    /** 工件码(框架码)前缀,若当前项目找不到则返回null */
+    public String getProductPrefix() {
+        Project p = getCurrentProject();
+        return p != null ? p.getProductPrefix() : null;
+    }
+
+    /** 冷板码前缀 */
+    public String getColdPlatePrefix() {
+        Project p = getCurrentProject();
+        return p != null ? p.getColdPlatePrefix() : null;
+    }
+
+    /** 底护板码前缀 */
+    public String getBottomPlatePrefix() {
+        Project p = getCurrentProject();
+        return p != null ? p.getBottomPlatePrefix() : null;
+    }
+
+    /**
+     * 根据物料标签获取前缀(供 ScanMaterialStep 区分)
+     * 支持"冷板码""底护板码"两种标签
+     */
+    public String getMaterialPrefixByLabel(String label) {
+        if (label == null) return null;
+        if (label.contains("冷板")) return getColdPlatePrefix();
+        if (label.contains("底护板")) return getBottomPlatePrefix();
+        return null;
+    }
+
+    // ========== 切换项目 ==========
+
+    public void switchProject(String projectId) {
+        Project p = findProject(projectId);
+        if (p == null) {
+            log.warn("[ProjectConfig] 无效的项目id: {}", projectId);
+            return;
+        }
+        if (p.getId().equalsIgnoreCase(currentProjectId)) return;
+        String old = currentProjectId;
+        currentProjectId = p.getId();
+        save();
+        log.info("[ProjectConfig] 切换项目: {} -> {}", old, currentProjectId);
+        fireProjectChanged(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 Boolean getDeviceRow1EnabledOverride() { return deviceRow1EnabledOverride; }
+    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 onProjectChanged(Project project) {}
+        default void onDeviceIpChanged(String newIp) {}
+        default void onFeaturesChanged() {}
+        default void onStationChanged() {}
+        default void onUiChanged() {}
+    }
+
+    public void addListener(Listener l) {
+        if (l != null && !listeners.contains(l)) listeners.add(l);
+    }
+
+    public void removeListener(Listener l) {
+        listeners.remove(l);
+    }
+
+    private void fireProjectChanged(Project p) {
+        for (Listener l : new ArrayList<>(listeners)) {
+            try { l.onProjectChanged(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 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); }
+        }
+    }
+}

+ 143 - 15
src/com/mes/core/StationConfig.java

@@ -37,6 +37,7 @@ public class StationConfig {
     // ========== 流程配置 ==========
     private List<StepConfig> workflowSteps;
     private String submitMode;               // manual | auto (手动点击OK/NG | 自动提交)
+    private Map<String, Boolean> yamlFeatures = new HashMap<>();  // yaml 中定义的功能默认开关
 
     // ========== 设备配置 ==========
     private boolean deviceEnabled;
@@ -125,6 +126,72 @@ public class StationConfig {
     }
 
     /**
+     * 展开 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")
@@ -167,24 +234,22 @@ public class StationConfig {
             if (submitMode == null || submitMode.isEmpty()) {
                 submitMode = "manual";  // 默认手动模式
             }
-            
-            List<Map<String, Object>> stepsArr = (List<Map<String, Object>>) workflowObj.get("steps");
-            if (stepsArr != null) {
-                for (Map<String, Object> step : stepsArr) {
-                    StepConfig stepConfig = new StepConfig();
-                    stepConfig.setId((String) step.get("id"));
-                    stepConfig.setName((String) step.get("name"));
-                    stepConfig.setCraft((String) step.get("craft"));
-
-                    // 解析步骤特定配置
-                    Map<String, Object> stepConfigMap = (Map<String, Object>) step.get("config");
-                    if (stepConfigMap != null) {
-                        stepConfig.setConfig(stepConfigMap);
-                    }
 
-                    workflowSteps.add(stepConfig);
+            // 解析功能开关(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);
+            }
         }
 
         // 解析设备配置
@@ -244,6 +309,8 @@ public class StationConfig {
                 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));
             }
             
             // 解析继电器配置
@@ -300,6 +367,35 @@ public class StationConfig {
                 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);
     }
 
     /**
@@ -450,6 +546,11 @@ public class StationConfig {
         return lineSn;
     }
 
+    /** 运行时覆盖线体号 */
+    public void setLineSn(String lineSn) {
+        this.lineSn = lineSn;
+    }
+
     public String getServerIp() {
         return serverIp;
     }
@@ -525,6 +626,14 @@ public class StationConfig {
         return showMaterialInput;
     }
 
+    /**
+     * 是否显示物料码输入框
+     * 直接返回 yaml 的 showMaterialInput(不再被 feature 强制覆盖)
+     */
+    public boolean isEffectiveShowMaterialInput() {
+        return showMaterialInput;
+    }
+
     public String getMaterialLabel() {
         return materialLabel;
     }
@@ -596,6 +705,11 @@ public class StationConfig {
             return code;
         }
 
+        /** 运行时覆盖工位号 */
+        public void setCode(String code) {
+            this.code = code;
+        }
+
         @Override
         public String toString() {
             return "StationInfo{code='" + code + "'}";
@@ -688,6 +802,11 @@ public class StationConfig {
             return ip;
         }
 
+        /** 运行时修改IP(用于软件内切换设备IP) */
+        public void setIp(String ip) {
+            this.ip = ip;
+        }
+
         public int getPort() {
             return port;
         }
@@ -755,6 +874,8 @@ public class StationConfig {
         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; }
@@ -762,6 +883,10 @@ public class StationConfig {
         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; }
     }
 
     /**
@@ -797,6 +922,7 @@ public class StationConfig {
         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; }
@@ -806,6 +932,8 @@ public class StationConfig {
         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();

+ 17 - 4
src/com/mes/core/StationContext.java

@@ -18,9 +18,9 @@ public class StationContext {
 
     // ========== 工位标识 ==========
     private final int stationIndex;         // 工位索引 0=工位1, 1=工位2
-    private final String stationCode;       // 工位号 OP150A(从配置读取)
-    private final String stationName;       // 工位名称
-    private final String lineSn;            // 线体号 XT
+    private String stationCode;             // 工位号 OP150A(从配置读取,支持运行时修改
+    private String stationName;             // 工位名称(支持运行时修改)
+    private String lineSn;                  // 线体号 XT(支持运行时修改)
 
     // ========== 当前工作数据 ==========
     private String productSn;               // 工件码
@@ -155,10 +155,16 @@ public class StationContext {
      * 从配置创建上下文
      */
     public static StationContext fromConfig(StationConfig.StationInfo stationInfo, String lineSn) {
+        // 工位名:按当前项目从 OprnoRegistry 查;查不到 fallback 到工位号
+        String projectId = ProjectConfigManager.getInstance().getCurrentProjectId();
+        String name = OprnoRegistry.getGwDes(projectId, stationInfo.getCode());
+        if (name == null || name.isEmpty()) {
+            name = stationInfo.getCode();
+        }
         return new StationContext(
                 stationInfo.getIndex(),
                 stationInfo.getCode(),
-                stationInfo.getCode(),
+                name,
                 lineSn
         );
     }
@@ -374,6 +380,13 @@ public class StationContext {
         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;
     }

+ 9 - 0
src/com/mes/device/DeviceDriverFactory.java

@@ -72,6 +72,15 @@ public class DeviceDriverFactory {
         connConfig.put("heartbeat_address", config.getHeartbeatAddress());
         connConfig.put("connect_timeout", config.getConnectTimeout());
 
+        // 心跳详细配置(超时阈值、间隔)
+        StationConfig.HeartbeatConfig hb = config.getHeartbeatConfig();
+        if (hb != null) {
+            connConfig.put("heartbeat_address", hb.getAddress());
+            connConfig.put("heartbeat_interval", hb.getInterval());
+            connConfig.put("heartbeat_timeout_address", hb.getTimeoutAddress());
+            connConfig.put("heartbeat_timeout_value", hb.getTimeoutValue());
+        }
+
         try {
             driver.connect(connConfig);
             log.info("Modbus TCP驱动创建成功: {}:{}", conn != null ? conn.getIp() : "?", conn != null ? conn.getPort() : "?");

+ 24 - 5
src/com/mes/device/ModbusTcpDriver.java

@@ -32,6 +32,9 @@ public class ModbusTcpDriver implements IDeviceDriver {
     private Timer heartbeatTimer;
     private short heartbeatValue = 1;
     private int heartbeatIntervalMs = 500;
+    // 心跳超时阈值(PLC 侧判断:单位100ms,10=1秒)
+    private int heartbeatTimeoutAddress = 4170;
+    private int heartbeatTimeoutValue = 10;
 
     // 超时配置
     private int connectTimeout = 3000;     // 连接超时(ms)
@@ -66,6 +69,15 @@ public class ModbusTcpDriver implements IDeviceDriver {
             if (config.containsKey("heartbeat_address")) {
                 this.heartbeatAddress = ((Number) config.get("heartbeat_address")).intValue();
             }
+            if (config.containsKey("heartbeat_timeout_address")) {
+                this.heartbeatTimeoutAddress = ((Number) config.get("heartbeat_timeout_address")).intValue();
+            }
+            if (config.containsKey("heartbeat_timeout_value")) {
+                this.heartbeatTimeoutValue = ((Number) config.get("heartbeat_timeout_value")).intValue();
+            }
+            if (config.containsKey("heartbeat_interval")) {
+                this.heartbeatIntervalMs = ((Number) config.get("heartbeat_interval")).intValue();
+            }
         }
 
         try {
@@ -124,6 +136,16 @@ public class ModbusTcpDriver implements IDeviceDriver {
     public void startHeartbeat() {
         stopHeartbeat();
 
+        // 先写心跳超时阈值到 heartbeatTimeoutAddress(单位100ms)
+        // 超过此时间未检测到心跳变化,PLC 判定为心跳丢失并禁止设备运行
+        try {
+            plc.writeInt16(heartbeatTimeoutAddress, (short) heartbeatTimeoutValue);
+            log.info("已写入心跳超时阈值: {}={} ({}ms)",
+                    heartbeatTimeoutAddress, heartbeatTimeoutValue, heartbeatTimeoutValue * 100);
+        } catch (Exception e) {
+            log.error("写入心跳超时阈值失败: {}", e.getMessage());
+        }
+
         heartbeatTimer = new Timer("PLCHeartbeat", true);
         heartbeatTimer.schedule(new TimerTask() {
             @Override
@@ -175,11 +197,9 @@ public class ModbusTcpDriver implements IDeviceDriver {
         if (!isConnected()) {
             return false;
         }
-
         try {
-            int finished = getFinishedCount();
-            int preset = getPresetCount();
-            // 完成数量达到预设数量视为完成
+            int finished = plc.readInt16(finishedCountAddress);
+            int preset = plc.readInt32(presetCountAddress);
             return preset > 0 && finished >= preset;
         } catch (Exception e) {
             log.error("检查工作完成状态失败: {}", e.getMessage());
@@ -358,7 +378,6 @@ public class ModbusTcpDriver implements IDeviceDriver {
             return;
         }
         try {
-            // 重置完成数量
             plc.writeInt16(finishedCountAddress, (short) 0);
             log.info("重置设备状态");
         } catch (Exception e) {

+ 7 - 0
src/com/mes/prod/ProdDataUploader.java

@@ -41,6 +41,13 @@ public class ProdDataUploader {
     }
 
     /**
+     * 清空所有采集器(用于功能切换时重建)
+     */
+    public void clearCollectors() {
+        collectors.clear();
+    }
+
+    /**
      * 启动定时上传
      */
     public void start() {

+ 14 - 7
src/com/mes/step/ScanMaterialStep.java

@@ -1,5 +1,6 @@
 package com.mes.step;
 
+import com.mes.core.ProjectConfigManager;
 import com.mes.core.StationContext;
 import com.mes.ui.component.WorkstationPanel;
 
@@ -7,11 +8,13 @@ import com.mes.ui.component.WorkstationPanel;
  * 扫描物料码步骤
  * 用于底护板绑定、冷板绑定等场景
  * 执行时自动弹出扫码框
+ * 前缀校验优先使用 ProjectConfigManager 按 label 匹配(冷板码/底护板码),
+ * 未匹配到则回退到 yaml 的 config.prefix
  */
 public class ScanMaterialStep extends AbstractStep {
 
     private String label = "物料码";  // 显示标签
-    private String prefix = "";       // 期望的前缀
+    private String prefix = "";       // yaml配置的前缀,作为fallback
     private boolean required = true;  // 是否必须
     private WorkstationPanel panel;   // 工位面板引用
 
@@ -57,17 +60,21 @@ public class ScanMaterialStep extends AbstractStep {
         // 用户扫码后:验证物料码
         String materialSn = context.getMaterialSn().trim();
 
-        // 前缀校验
-        if (prefix != null && !prefix.isEmpty() && !materialSn.startsWith(prefix)) {
-            log.warn("[{}] {}前缀错误: {},期望前缀: {}", context.getStationCode(), label, materialSn, prefix);
-            context.setStatusMessage(label + "前缀应为 " + prefix, -1);
+        // 前缀校验:优先按 label 从项目配置读取,fallback 到 yaml
+        String effectivePrefix = ProjectConfigManager.getInstance().getMaterialPrefixByLabel(label);
+        if (effectivePrefix == null || effectivePrefix.isEmpty()) {
+            effectivePrefix = prefix;
+        }
+        if (effectivePrefix != null && !effectivePrefix.isEmpty() && !materialSn.startsWith(effectivePrefix)) {
+            log.warn("[{}] {}前缀错误: {},期望前缀: {}", context.getStationCode(), label, materialSn, effectivePrefix);
+            context.setStatusMessage(label + "前缀应为 " + effectivePrefix, -1);
             context.setMaterialSn(null);
-            
+
             // 格式错误,重新弹出扫码框
             if (panel != null) {
                 panel.requestMaterialScan();
             }
-            
+
             return false;
         }
 

+ 12 - 6
src/com/mes/step/ScanProductStep.java

@@ -1,15 +1,17 @@
 package com.mes.step;
 
+import com.mes.core.ProjectConfigManager;
 import com.mes.core.StationContext;
 
 /**
  * 扫描工件码步骤
  * 等待用户扫描工件码,然后自动进入下一步
- * 支持通过config.prefix配置前缀校验
+ * 前缀校验优先使用 ProjectConfigManager 的当前项目前缀;
+ * 若未配置项目前缀,则回退到 yaml 的 config.prefix
  */
 public class ScanProductStep extends AbstractStep {
 
-    private String prefix = "";  // 期望的前缀,从config读取
+    private String prefix = "";  // yaml配置的前缀,作为fallback
 
     public ScanProductStep() {
         super("scan_product", "扫描工件码");
@@ -40,10 +42,14 @@ public class ScanProductStep extends AbstractStep {
             log.info("[{}] 36位码处理: {} -> {}", context.getStationCode(), productSn, processedSn);
         }
 
-        // 前缀校验
-        if (prefix != null && !prefix.isEmpty() && !processedSn.startsWith(prefix)) {
-            log.warn("[{}] 工件码前缀错误: {},期望前缀: {}", context.getStationCode(), processedSn, prefix);
-            context.setStatusMessage("工件码前缀应为 " + prefix, -1);
+        // 前缀校验:优先读项目配置,fallback 到 yaml
+        String effectivePrefix = ProjectConfigManager.getInstance().getProductPrefix();
+        if (effectivePrefix == null || effectivePrefix.isEmpty()) {
+            effectivePrefix = prefix;
+        }
+        if (effectivePrefix != null && !effectivePrefix.isEmpty() && !processedSn.startsWith(effectivePrefix)) {
+            log.warn("[{}] 工件码前缀错误: {},期望前缀: {}", context.getStationCode(), processedSn, effectivePrefix);
+            context.setStatusMessage("工件码前缀应为 " + effectivePrefix, -1);
             context.setProductSn(null);  // 清空,让用户重新扫
             return false;
         }

+ 787 - 45
src/com/mes/ui/MainFrame.java

@@ -75,10 +75,20 @@ public class MainFrame extends JFrame {
     // 需要密码解锁的设置菜单项
     private JSeparator editModeSeparator;
     private JCheckBoxMenuItem editModeItem;
-    private JSeparator layoutSeparator;
     private JMenuItem saveLayoutItem;
     private JMenuItem exportLayoutItem;
     private JMenuItem importLayoutItem;
+    // 项目/设备子菜单(mes123解锁后显示)
+    private JMenu projectDeviceMenu;
+    private JMenuItem switchProjectItem;
+    private JMenuItem switchStationItem;
+    private JMenuItem deviceIpItem;
+    private JMenuItem featuresItem;
+    private JMenuItem deviceInfoUiItem;
+    // 界面布局子菜单(mes123解锁后显示)
+    private JMenu layoutMenu;
+    // 工具栏项目显示按钮
+    private JButton projectMenu;
     
     // 三击解锁相关
     private int userMenuClickCount = 0;
@@ -86,6 +96,12 @@ public class MainFrame extends JFrame {
     private static final int TRIPLE_CLICK_INTERVAL = 1500; // 1.5秒内完成3次点击
     private static final String WORKFLOW_PASSWORD = "mes123";
 
+    // 免密码解锁序列:2次用户 -> 2次项目 -> 2次心跳
+    private int unlockStage = 0;         // 0=等用户, 1=等项目, 2=等心跳
+    private int unlockStageCount = 0;    // 当前阶段已点击次数
+    private long unlockLastClickTime = 0;
+    private static final int UNLOCK_INTERVAL = 2000; // 每次点击最大间隔 2s
+
     public MainFrame() {
         this(StationConfig.getInstance());
     }
@@ -107,9 +123,19 @@ public class MainFrame extends JFrame {
      * 初始化窗口
      */
     private void initFrame() {
-        String title = "MES系统客户端:" + getStationCodesString();
-        if (config.getStationName() != null && !config.getStationName().isEmpty()) {
-            title += " - " + config.getStationName();
+        String codes = getStationCodesString();
+        // 标题优先用 OprnoRegistry 按当前项目推导的工位名称,其次 yaml 的 name
+        String projectId = com.mes.core.ProjectConfigManager.getInstance().getCurrentProjectId();
+        String name = "";
+        if (!config.getStations().isEmpty()) {
+            name = com.mes.core.OprnoRegistry.getGwDes(projectId, config.getStations().get(0).getCode());
+        }
+        if ((name == null || name.isEmpty()) && config.getStationName() != null) {
+            name = config.getStationName();
+        }
+        String title = "MES系统客户端:" + codes;
+        if (name != null && !name.isEmpty() && !name.equals(codes)) {
+            title += " - " + name;
         }
         setTitle(title);
 
@@ -195,44 +221,77 @@ public class MainFrame extends JFrame {
         editModeSeparator = new JSeparator();
         editModeSeparator.setVisible(false);
         settingMenu.add(editModeSeparator);
-        
-        // UI编辑模式(默认隐藏,密码解锁后显示)
+
+        // ========== 子菜单:项目与设备(默认隐藏,密码解锁后显示) ==========
+        projectDeviceMenu = new JMenu("项目与设备");
+        projectDeviceMenu.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/menu_setting.png"))));
+        projectDeviceMenu.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        projectDeviceMenu.setVisible(false);
+        settingMenu.add(projectDeviceMenu);
+
+        switchProjectItem = new JMenuItem();
+        switchProjectItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/menu_setting.png"))));
+        switchProjectItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        switchProjectItem.addActionListener(e -> showSwitchProjectDialog());
+        projectDeviceMenu.add(switchProjectItem);
+
+        switchStationItem = new JMenuItem();
+        switchStationItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/menu_setting.png"))));
+        switchStationItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        switchStationItem.addActionListener(e -> showSwitchStationDialog());
+        projectDeviceMenu.add(switchStationItem);
+
+        deviceIpItem = new JMenuItem();
+        deviceIpItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/menu_setting.png"))));
+        deviceIpItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        deviceIpItem.addActionListener(e -> showChangeDeviceIpDialog());
+        projectDeviceMenu.add(deviceIpItem);
+
+        projectDeviceMenu.addSeparator();
+
+        featuresItem = new JMenuItem("工艺流程配置");
+        featuresItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/menu_setting.png"))));
+        featuresItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        featuresItem.addActionListener(e -> showFeaturesDialog());
+        projectDeviceMenu.add(featuresItem);
+
+        deviceInfoUiItem = new JMenuItem("设备信息显示...");
+        deviceInfoUiItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/menu_setting.png"))));
+        deviceInfoUiItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        deviceInfoUiItem.addActionListener(e -> showDeviceInfoUiDialog());
+        projectDeviceMenu.add(deviceInfoUiItem);
+
+        refreshMenuTexts();  // 首次填充显示当前值
+
+        // ========== 子菜单:界面布局(默认隐藏,密码解锁后显示) ==========
+        layoutMenu = new JMenu("界面布局");
+        layoutMenu.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/bar_setting.png"))));
+        layoutMenu.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        layoutMenu.setVisible(false);
+        settingMenu.add(layoutMenu);
+
         editModeItem = new JCheckBoxMenuItem("UI编辑模式", false);
         editModeItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
-        editModeItem.addItemListener(e -> {
-            boolean selected = editModeItem.isSelected();
-            toggleEditMode(selected);
-        });
-        editModeItem.setVisible(false);
-        settingMenu.add(editModeItem);
-        
-        layoutSeparator = new JSeparator();
-        layoutSeparator.setVisible(false);
-        settingMenu.add(layoutSeparator);
-        
-        // 保存UI布局(默认隐藏,密码解锁后显示)
+        editModeItem.addItemListener(e -> toggleEditMode(editModeItem.isSelected()));
+        layoutMenu.add(editModeItem);
+
         saveLayoutItem = new JMenuItem("保存UI布局");
         saveLayoutItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/save_bg.png"))));
         saveLayoutItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
         saveLayoutItem.addActionListener(e -> saveUILayout());
-        saveLayoutItem.setVisible(false);
-        settingMenu.add(saveLayoutItem);
-        
-        // 导出布局配置(默认隐藏,密码解锁后显示)
+        layoutMenu.add(saveLayoutItem);
+
         exportLayoutItem = new JMenuItem("导出布局配置");
         exportLayoutItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/download.png"))));
         exportLayoutItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
         exportLayoutItem.addActionListener(e -> exportUILayout());
-        exportLayoutItem.setVisible(false);
-        settingMenu.add(exportLayoutItem);
-        
-        // 导入布局配置(默认隐藏,密码解锁后显示)
+        layoutMenu.add(exportLayoutItem);
+
         importLayoutItem = new JMenuItem("导入布局配置");
         importLayoutItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/open_file.png"))));
         importLayoutItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
         importLayoutItem.addActionListener(e -> importUILayout());
-        importLayoutItem.setVisible(false);
-        settingMenu.add(importLayoutItem);
+        layoutMenu.add(importLayoutItem);
 
         // 内容面板
         contentPane = new JPanel();
@@ -274,6 +333,7 @@ public class MainFrame extends JFrame {
         heartBeatMenu.setForeground(Color.GREEN);
         heartBeatMenu.setFont(new Font("微软雅黑", Font.PLAIN, 20));
         heartBeatMenu.setBackground(Color.BLACK);
+        heartBeatMenu.addActionListener(e -> onUnlockClick("heart"));
         toolBar.add(heartBeatMenu);
 
         toolBar.add(new JLabel("    "));
@@ -293,6 +353,33 @@ public class MainFrame extends JFrame {
         userMenu.addActionListener(e -> onUserMenuClicked());
         toolBar.add(userMenu);
 
+        toolBar.add(new JLabel("    "));
+
+        // 当前项目显示(只读展示,切换在"设置"菜单中)
+        JLabel projectLabel = new JLabel("项目:");
+        projectLabel.setHorizontalAlignment(SwingConstants.CENTER);
+        projectLabel.setForeground(Color.BLACK);
+        projectLabel.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        projectLabel.setBackground(Color.LIGHT_GRAY);
+        toolBar.add(projectLabel);
+
+        projectMenu = new JButton("");
+        projectMenu.setForeground(Color.YELLOW);
+        projectMenu.setFont(new Font("微软雅黑", Font.PLAIN, 22));
+        projectMenu.setBackground(Color.BLACK);
+        projectMenu.setFocusable(false);
+        projectMenu.addActionListener(e -> onUnlockClick("project"));
+        toolBar.add(projectMenu);
+        updateProjectDisplay();
+
+        // 订阅项目切换 → 刷新工具栏显示
+        com.mes.core.ProjectConfigManager.getInstance().addListener(new com.mes.core.ProjectConfigManager.Listener() {
+            @Override
+            public void onProjectChanged(com.mes.core.ProjectConfigManager.Project project) {
+                SwingUtilities.invokeLater(() -> updateProjectDisplay());
+            }
+        });
+
         // 标签页
         tabbedPane = new JTabbedPane(JTabbedPane.TOP);
         tabbedPane.setMinimumSize(new Dimension(400, 50));
@@ -996,31 +1083,77 @@ public class MainFrame extends JFrame {
     // ========== 流程Tab解锁相关方法 ==========
     
     /**
-     * 用户按钮点击处理 - 三击解锁流程Tab
+     * 用户按钮点击处理
+     * - 同步驱动"2用户→2项目→2心跳"免密码序列
+     * - 保留原三击密码弹框解锁
      */
     private void onUserMenuClicked() {
+        // 1) 先处理免密码序列
+        onUnlockClick("user");
+
+        // 2) 保留三击密码解锁
         long now = System.currentTimeMillis();
-        
         if (now - lastUserMenuClickTime > TRIPLE_CLICK_INTERVAL) {
-            // 超时,重新计数
             userMenuClickCount = 1;
         } else {
             userMenuClickCount++;
         }
         lastUserMenuClickTime = now;
-        
+
         if (userMenuClickCount >= 3) {
             userMenuClickCount = 0;
-            
             if (workflowTabVisible) {
-                // 已显示,再次三击则隐藏
                 hideWorkflowTab();
             } else {
-                // 弹出密码输入框
                 showWorkflowPasswordDialog();
             }
         }
     }
+
+    /**
+     * 免密码解锁序列:用户x2 → 项目x2 → 心跳x2
+     * 每次点击间隔超过 UNLOCK_INTERVAL 或点错按钮则重置
+     */
+    private void onUnlockClick(String source) {
+        long now = System.currentTimeMillis();
+        if (now - unlockLastClickTime > UNLOCK_INTERVAL) {
+            // 超时重置
+            unlockStage = 0;
+            unlockStageCount = 0;
+        }
+        unlockLastClickTime = now;
+
+        String expected;
+        switch (unlockStage) {
+            case 0: expected = "user";    break;
+            case 1: expected = "project"; break;
+            case 2: expected = "heart";   break;
+            default: unlockStage = 0; unlockStageCount = 0; return;
+        }
+        if (!expected.equals(source)) {
+            // 点错按钮,重置(但如果 source==user 说明可能是新一轮开始)
+            unlockStage = 0;
+            unlockStageCount = "user".equals(source) ? 1 : 0;
+            return;
+        }
+
+        unlockStageCount++;
+        if (unlockStageCount >= 2) {
+            // 进入下一阶段
+            unlockStage++;
+            unlockStageCount = 0;
+            if (unlockStage >= 3) {
+                // 六步完成,解锁
+                unlockStage = 0;
+                if (workflowTabVisible) {
+                    hideWorkflowTab();
+                } else {
+                    showWorkflowTab();
+                    log.info("免密码解锁成功");
+                }
+            }
+        }
+    }
     
     /**
      * 显示密码输入对话框
@@ -1059,11 +1192,8 @@ public class MainFrame extends JFrame {
         
         // 同时显示隐藏的设置菜单项
         editModeSeparator.setVisible(true);
-        editModeItem.setVisible(true);
-        layoutSeparator.setVisible(true);
-        saveLayoutItem.setVisible(true);
-        exportLayoutItem.setVisible(true);
-        importLayoutItem.setVisible(true);
+        projectDeviceMenu.setVisible(true);
+        layoutMenu.setVisible(true);
     }
     
     /**
@@ -1078,10 +1208,622 @@ public class MainFrame extends JFrame {
         
         // 同时隐藏设置菜单项
         editModeSeparator.setVisible(false);
-        editModeItem.setVisible(false);
-        layoutSeparator.setVisible(false);
-        saveLayoutItem.setVisible(false);
-        exportLayoutItem.setVisible(false);
-        importLayoutItem.setVisible(false);
+        projectDeviceMenu.setVisible(false);
+        layoutMenu.setVisible(false);
+    }
+
+    // ========== 项目切换/设备IP修改 ==========
+
+    /**
+     * 刷新工具栏上的项目显示
+     */
+    private void updateProjectDisplay() {
+        if (projectMenu == null) return;
+        com.mes.core.ProjectConfigManager pc = com.mes.core.ProjectConfigManager.getInstance();
+        com.mes.core.ProjectConfigManager.Project p = pc.getCurrentProject();
+        if (p != null) {
+            projectMenu.setText(p.getDisplay());
+            projectMenu.setToolTipText(String.format(
+                    "<html>当前项目:%s<br>工件码前缀:%s<br>冷板码前缀:%s<br>底护板码前缀:%s</html>",
+                    p.getDisplay(), p.getProductPrefix(), p.getColdPlatePrefix(), p.getBottomPlatePrefix()));
+        }
+    }
+
+    /**
+     * 弹出切换项目对话框
+     */
+    private void showSwitchProjectDialog() {
+        com.mes.core.ProjectConfigManager pc = com.mes.core.ProjectConfigManager.getInstance();
+        java.util.List<com.mes.core.ProjectConfigManager.Project> projects = pc.getProjects();
+
+        String[] options = new String[projects.size()];
+        int currentIndex = 0;
+        for (int i = 0; i < projects.size(); i++) {
+            com.mes.core.ProjectConfigManager.Project p = projects.get(i);
+            options[i] = String.format("%s  (工件:%s 冷板:%s 底护板:%s)",
+                    p.getDisplay(), p.getProductPrefix(), p.getColdPlatePrefix(), p.getBottomPlatePrefix());
+            if (p.getId().equalsIgnoreCase(pc.getCurrentProjectId())) {
+                currentIndex = i;
+            }
+        }
+
+        JComboBox<String> combo = new JComboBox<>(options);
+        combo.setSelectedIndex(currentIndex);
+        combo.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+
+        JPanel panel = new JPanel(new BorderLayout(5, 5));
+        panel.add(new JLabel("选择项目:"), BorderLayout.NORTH);
+        panel.add(combo, BorderLayout.CENTER);
+
+        int result = JOptionPane.showConfirmDialog(this, panel,
+                "切换项目", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
+        if (result != JOptionPane.OK_OPTION) return;
+
+        int idx = combo.getSelectedIndex();
+        if (idx < 0 || idx >= projects.size()) return;
+        com.mes.core.ProjectConfigManager.Project target = projects.get(idx);
+        if (target.getId().equalsIgnoreCase(pc.getCurrentProjectId())) {
+            return;
+        }
+        pc.switchProject(target.getId());
+
+        // 重置所有工位,避免沿用旧项目扫到一半的码
+        for (int i = 0; i < engines.size(); i++) {
+            engines.get(i).reset();
+            if (i < panels.size()) panels.get(i).reset();
+        }
+
+        // 切换项目后同步工位名(标题 + context 的 stationName)
+        String newProjectId = target.getId();
+        for (StationContext ctx : contexts) {
+            String newName = com.mes.core.OprnoRegistry.getGwDes(newProjectId, ctx.getStationCode());
+            if (newName == null || newName.isEmpty()) newName = ctx.getStationCode();
+            ctx.updateStation(null, newName, null);
+        }
+        if (!config.getStations().isEmpty()) {
+            String code = config.getStations().get(0).getCode();
+            String name = com.mes.core.OprnoRegistry.getGwDes(newProjectId, code);
+            String title = "MES系统客户端:" + code;
+            if (name != null && !name.isEmpty() && !name.equals(code)) title += " - " + name;
+            setTitle(title);
+        }
+        refreshMenuTexts();
+        updateProjectDisplay();
+
+        JOptionPane.showMessageDialog(this,
+                "已切换到:" + target.getDisplay() + "\n前缀校验立即生效,所有工位已重置",
+                "切换成功", JOptionPane.INFORMATION_MESSAGE);
+    }
+
+    /**
+     * 弹出修改拉铆设备IP对话框
+     */
+    private void showChangeDeviceIpDialog() {
+        com.mes.core.ProjectConfigManager pc = com.mes.core.ProjectConfigManager.getInstance();
+        String currentIp = pc.getDeviceIp();
+
+        String input = (String) JOptionPane.showInputDialog(this,
+                "请输入拉铆设备IP:",
+                "修改拉铆设备IP",
+                JOptionPane.PLAIN_MESSAGE,
+                null, null, currentIp);
+
+        if (input == null) return;
+        input = input.trim();
+        if (input.isEmpty()) return;
+        if (!isValidIp(input)) {
+            JOptionPane.showMessageDialog(this, "IP格式不正确: " + input,
+                    "错误", JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+        if (input.equals(currentIp)) return;
+
+        // 1. 持久化
+        pc.setDeviceIp(input);
+        // 2. 覆盖当前 StationConfig 中的连接IP
+        for (StationConfig.DeviceConnection conn : config.getDeviceConnections()) {
+            conn.setIp(input);
+        }
+        // 3. 断开并重连设备驱动
+        reconnectDeviceDrivers();
+        refreshMenuTexts();
+
+        JOptionPane.showMessageDialog(this,
+                "拉铆设备IP已更新为:" + input + "\n已尝试重新连接设备",
+                "修改成功", JOptionPane.INFORMATION_MESSAGE);
+    }
+
+    /**
+     * 断开并重新创建设备驱动,重新注入到各工位上下文
+     */
+    private void reconnectDeviceDrivers() {
+        // 断开旧驱动
+        if (deviceDrivers != null) {
+            for (IDeviceDriver d : deviceDrivers.values()) {
+                try { d.disconnect(); } catch (Exception e) { log.warn("断开旧设备驱动异常", e); }
+            }
+        }
+
+        // 重新创建
+        Map<Integer, IDeviceDriver> newDrivers = new HashMap<>();
+        if (config.isDeviceEnabled()) {
+            newDrivers = DeviceDriverFactory.createDrivers(config);
+        }
+        this.deviceDrivers = newDrivers;
+
+        // 重新注入到每个工位上下文
+        for (int i = 0; i < contexts.size(); i++) {
+            StationContext ctx = contexts.get(i);
+            if (deviceDrivers.containsKey(i)) {
+                ctx.setDeviceDriver(deviceDrivers.get(i));
+            }
+            StationConfig.DeviceInfoRow row2 = config.getDeviceInfoRow(1);
+            if (row2 != null) {
+                int connIdx = row2.getConnectionIndex();
+                if (deviceDrivers.containsKey(connIdx)) {
+                    ctx.setDeviceDriver2(deviceDrivers.get(connIdx));
+                }
+            }
+        }
+        log.info("设备驱动已重新连接: IP={}", com.mes.core.ProjectConfigManager.getInstance().getDeviceIp());
+    }
+
+    /**
+     * 简单的IPv4格式校验
+     */
+    private boolean isValidIp(String ip) {
+        if (ip == null) return false;
+        String[] parts = ip.split("\\.");
+        if (parts.length != 4) return false;
+        for (String part : parts) {
+            try {
+                int n = Integer.parseInt(part);
+                if (n < 0 || n > 255) return false;
+            } catch (NumberFormatException e) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 刷新"项目与设备"子菜单的显示文本,把当前值显示在菜单项后面
+     */
+    private void refreshMenuTexts() {
+        com.mes.core.ProjectConfigManager pc = com.mes.core.ProjectConfigManager.getInstance();
+        if (switchProjectItem != null) {
+            com.mes.core.ProjectConfigManager.Project p = pc.getCurrentProject();
+            switchProjectItem.setText("切换项目 (当前: " + (p != null ? p.getDisplay() : "?") + ")");
+        }
+        if (switchStationItem != null && !config.getStations().isEmpty()) {
+            String code = config.getStations().get(0).getCode();
+            String des = com.mes.core.OprnoRegistry.getGwDes(pc.getCurrentProjectId(), code);
+            if (des == null || des.isEmpty()) des = "未知工位";
+            switchStationItem.setText("修改工位号 (当前: " + code + " - " + des + ")");
+        }
+        if (deviceIpItem != null) {
+            deviceIpItem.setText("修改拉铆设备IP (当前: " + pc.getDeviceIp() + ")");
+        }
+    }
+
+    /**
+     * 弹出修改工位号对话框
+     * 工位列表按当前项目(P1-P4)展示;工位名称由 OprnoRegistry 自动推导
+     */
+    private void showSwitchStationDialog() {
+        com.mes.core.ProjectConfigManager pc = com.mes.core.ProjectConfigManager.getInstance();
+        String projectId = pc.getCurrentProjectId();
+        com.mes.core.ProjectConfigManager.Project project = pc.getCurrentProject();
+        String currentCode = config.getStations().isEmpty() ? "" : config.getStations().get(0).getCode();
+
+        // 拆分当前code为 "基础码" + "后缀"
+        String currentBase = com.mes.core.OprnoRegistry.formatOprno(currentCode);
+        String currentSuffix = currentCode.length() > currentBase.length()
+                ? currentCode.substring(currentBase.length()).toUpperCase() : "";
+
+        // 工位号下拉(固定项,不可编辑)
+        JComboBox<String> codeCombo = new JComboBox<>();
+        codeCombo.setEditable(false);
+        codeCombo.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        for (String c : com.mes.core.OprnoRegistry.listCodes(projectId)) {
+            codeCombo.addItem(c);
+        }
+        if (com.mes.core.OprnoRegistry.listCodes(projectId).contains(currentBase)) {
+            codeCombo.setSelectedItem(currentBase);
+        }
+
+        // 后缀下拉(空 / A-L)
+        String[] suffixes = new String[]{"(无)", "A","B","C","D","E","F","G","H","I","J","K","L"};
+        JComboBox<String> suffixCombo = new JComboBox<>(suffixes);
+        suffixCombo.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        suffixCombo.setSelectedItem(currentSuffix.isEmpty() ? "(无)" : currentSuffix);
+
+        // 预览
+        JLabel previewLabel = new JLabel(" ");
+        previewLabel.setFont(new Font("微软雅黑", Font.BOLD, 16));
+        previewLabel.setForeground(new Color(0x1c, 0x6e, 0xa4));
+
+        // 工位名显示
+        JLabel desLabel = new JLabel(" ");
+        desLabel.setFont(new Font("微软雅黑", Font.PLAIN, 14));
+        desLabel.setForeground(new Color(0x3a, 0x7a, 0xc0));
+
+        Runnable refresh = () -> {
+            String base = (String) codeCombo.getSelectedItem();
+            String sfx = (String) suffixCombo.getSelectedItem();
+            if (base == null) base = "";
+            String sfxReal = ("(无)".equals(sfx) || sfx == null) ? "" : sfx;
+            String full = base + sfxReal;
+            previewLabel.setText("最终工位号: " + (full.isEmpty() ? "-" : full));
+            String des = com.mes.core.OprnoRegistry.getGwDes(projectId, full);
+            desLabel.setText("工位名称: " + (des.isEmpty() ? "(未知工位)" : des));
+        };
+        refresh.run();
+        codeCombo.addActionListener(e -> refresh.run());
+        suffixCombo.addActionListener(e -> refresh.run());
+
+        JPanel panel = new JPanel(new java.awt.GridBagLayout());
+        java.awt.GridBagConstraints gbc = new java.awt.GridBagConstraints();
+        gbc.insets = new Insets(4, 4, 4, 4);
+        gbc.anchor = java.awt.GridBagConstraints.WEST;
+
+        gbc.gridx = 0; gbc.gridy = 0;
+        panel.add(new JLabel("当前项目:"), gbc);
+        gbc.gridx = 1; gbc.fill = java.awt.GridBagConstraints.HORIZONTAL;
+        JLabel projectLbl = new JLabel(project != null ? project.getDisplay() : projectId);
+        projectLbl.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        panel.add(projectLbl, gbc);
+
+        gbc.gridx = 0; gbc.gridy = 1; gbc.fill = java.awt.GridBagConstraints.NONE;
+        panel.add(new JLabel("工位号:"), gbc);
+        gbc.gridx = 1; gbc.fill = java.awt.GridBagConstraints.HORIZONTAL;
+        panel.add(codeCombo, gbc);
+
+        gbc.gridx = 0; gbc.gridy = 2; gbc.fill = java.awt.GridBagConstraints.NONE;
+        panel.add(new JLabel("后缀:"), gbc);
+        gbc.gridx = 1; gbc.fill = java.awt.GridBagConstraints.HORIZONTAL;
+        panel.add(suffixCombo, gbc);
+
+        gbc.gridx = 0; gbc.gridy = 3; gbc.gridwidth = 2;
+        panel.add(previewLabel, gbc);
+        gbc.gridy = 4;
+        panel.add(desLabel, gbc);
+
+        int result = JOptionPane.showConfirmDialog(this, panel, "修改工位号",
+                JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
+        if (result != JOptionPane.OK_OPTION) return;
+
+        String base = (String) codeCombo.getSelectedItem();
+        String sfx = (String) suffixCombo.getSelectedItem();
+        String sfxReal = ("(无)".equals(sfx) || sfx == null) ? "" : sfx;
+        String newCode = (base == null ? "" : base) + sfxReal;
+        if (newCode.isEmpty()) {
+            JOptionPane.showMessageDialog(this, "工位号不能为空", "错误", JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+        if (newCode.equalsIgnoreCase(currentCode)) return;
+
+        pc.setStationOverride(newCode, null);
+        applyStationChange(newCode);
+
+        String des = com.mes.core.OprnoRegistry.getGwDes(projectId, newCode);
+        JOptionPane.showMessageDialog(this,
+                "工位已更新为:" + newCode + (des.isEmpty() ? "" : (" - " + des)) + "\n已刷新界面并向MES重新同步",
+                "修改成功", JOptionPane.INFORMATION_MESSAGE);
+    }
+
+    /**
+     * 热更新工位号:更新 config/context/dispatcher/面板显示/窗口标题,并重新向MES同步
+     */
+    private void applyStationChange(String newCode) {
+        String oldCode = config.getStations().isEmpty() ? "" : config.getStations().get(0).getCode();
+        String projectId = com.mes.core.ProjectConfigManager.getInstance().getCurrentProjectId();
+
+        // 1. 更新 config
+        if (!config.getStations().isEmpty()) {
+            config.getStations().get(0).setCode(newCode);
+        }
+
+        // 2. 更新 context(推导工位名)
+        String name = com.mes.core.OprnoRegistry.getGwDes(projectId, newCode);
+        if (name == null || name.isEmpty()) name = newCode;
+        for (StationContext ctx : contexts) {
+            if (ctx.getStationIndex() == 0) {
+                ctx.updateStation(newCode, name, null);
+            }
+        }
+
+        // 3. dispatcher 注销旧的、注册新的
+        if (messageDispatcher != null && !oldCode.isEmpty() && !oldCode.equalsIgnoreCase(newCode)) {
+            messageDispatcher.unregisterStation(oldCode);
+            if (!engines.isEmpty() && !contexts.isEmpty()) {
+                messageDispatcher.registerStation(newCode, engines.get(0), contexts.get(0));
+            }
+        }
+
+        // 4. 刷新 WorkstationPanel 工位标签
+        for (WorkstationPanel p : panels) {
+            p.updateStationCode(newCode);
+        }
+
+        // 5. 重置工位并向 MES 重新同步
+        for (int i = 0; i < engines.size(); i++) {
+            engines.get(i).reset();
+            if (i < panels.size()) panels.get(i).reset();
+            contexts.get(i).setStatusMessage("工位已切换,请扫下一件", 0);
+        }
+        if (tcpClient != null && !contexts.isEmpty()) {
+            tcpClient.sendSync(contexts.get(0).getStationCode());
+        }
+
+        // 5b. 重建过程参数采集器(collector 以工位号为 key)
+        if (prodDataUploader != null && config.hasProdParams()) {
+            prodDataUploader.clearCollectors();
+            for (int i = 0; i < contexts.size(); i++) {
+                StationContext c = contexts.get(i);
+                ProdDataCollector collector = new ProdDataCollector(config.getProdParamsConfig());
+                WorkflowEngine eng = engines.get(i);
+                for (com.mes.step.IWorkflowStep step : eng.getSteps()) {
+                    if (step instanceof WaitCompleteStep) {
+                        ((WaitCompleteStep) step).setProdDataCollector(collector);
+                    }
+                }
+                prodDataUploader.addCollector(c.getStationCode(), collector);
+            }
+        }
+
+        // 6. 窗口标题
+        String title = "MES系统客户端:" + newCode;
+        if (!name.isEmpty() && !name.equals(newCode)) title += " - " + name;
+        setTitle(title);
+
+        // 7. 刷新菜单文字
+        refreshMenuTexts();
+
+        log.info("工位已切换: {} -> {} ({})", oldCode, newCode, name);
+    }
+
+    /**
+     * 弹出工艺流程配置对话框
+     * 用户勾选启用的功能,保存后软重启所有工位流程
+     */
+    private void showFeaturesDialog() {
+        com.mes.core.ProjectConfigManager pc = com.mes.core.ProjectConfigManager.getInstance();
+        // yaml 定义的功能集合 + 当前有效值
+        Map<String, Boolean> yamlFeatures = config.getYamlFeatures();
+        if (yamlFeatures.isEmpty()) {
+            JOptionPane.showMessageDialog(this, "station.yaml 中未定义 workflow.features",
+                    "提示", JOptionPane.WARNING_MESSAGE);
+            return;
+        }
+        // 功能显示名映射
+        Map<String, String> labels = new LinkedHashMap<>();
+        labels.put("cold_plate",   "扫+校验冷板码");
+        labels.put("bottom_plate", "扫+校验底护板码");
+        labels.put("riveting",     "拉铆流程(心跳+计数+等待+停心跳)");
+        labels.put("prod_params",  "拉铆过程参数采集");
+
+        // 构建复选框(按 yaml 声明顺序)
+        JPanel panel = new JPanel();
+        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+        panel.setBorder(BorderFactory.createEmptyBorder(10, 15, 10, 15));
+
+        Map<String, JCheckBox> checkBoxes = new LinkedHashMap<>();
+        for (String key : yamlFeatures.keySet()) {
+            String label = labels.getOrDefault(key, key);
+            JCheckBox cb = new JCheckBox(label, config.isFeatureEnabled(key));
+            cb.setFont(new Font("微软雅黑", Font.PLAIN, 18));
+            cb.setAlignmentX(Component.LEFT_ALIGNMENT);
+            panel.add(cb);
+            panel.add(Box.createVerticalStrut(6));
+            checkBoxes.put(key, cb);
+        }
+        // cold_plate 与 bottom_plate 互斥(勾一个自动取消另一个)
+        final JCheckBox cold = checkBoxes.get("cold_plate");
+        final JCheckBox bottom = checkBoxes.get("bottom_plate");
+        if (cold != null && bottom != null) {
+            cold.addActionListener(ev -> { if (cold.isSelected()) bottom.setSelected(false); });
+            bottom.addActionListener(ev -> { if (bottom.isSelected()) cold.setSelected(false); });
+        }
+        JLabel hint = new JLabel("<html><font color='gray' size='3'>冷板/底护板二选一;保存后将重建所有工位流程并重置</font></html>");
+        hint.setAlignmentX(Component.LEFT_ALIGNMENT);
+        panel.add(hint);
+
+        int result = JOptionPane.showConfirmDialog(this, panel,
+                "工艺流程配置", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
+        if (result != JOptionPane.OK_OPTION) return;
+
+        // 收集勾选结果
+        Map<String, Boolean> override = new LinkedHashMap<>();
+        for (Map.Entry<String, JCheckBox> e : checkBoxes.entrySet()) {
+            override.put(e.getKey(), e.getValue().isSelected());
+        }
+        // 二次兜底:如果用户绕过监听同时勾了两个,强制只保留冷板
+        if (Boolean.TRUE.equals(override.get("cold_plate"))
+                && Boolean.TRUE.equals(override.get("bottom_plate"))) {
+            override.put("bottom_plate", false);
+        }
+        pc.setFeaturesOverride(override);
+
+        // 物料码输入框联动:勾了冷板或底护板 → 显示;都没勾 → 隐藏
+        boolean coldOn = Boolean.TRUE.equals(override.get("cold_plate"));
+        boolean bottomOn = Boolean.TRUE.equals(override.get("bottom_plate"));
+        pc.setDeviceInfoRowsOverride(
+                pc.getDeviceRow1EnabledOverride(), pc.getDeviceRow1LabelOverride(),
+                pc.getDeviceRow2EnabledOverride(), pc.getDeviceRow2LabelOverride(),
+                coldOn || bottomOn);
+
+        // 重新加载 yaml 配置(会按新 override 展开 steps)
+        StationConfig.reload();
+        this.config = StationConfig.getInstance();
+        // 重新应用当前设备IP覆盖
+        String ip = pc.getDeviceIp();
+        for (StationConfig.DeviceConnection conn : config.getDeviceConnections()) {
+            conn.setIp(ip);
+        }
+
+        rebuildWorkflows();
+
+        // 按功能开关同步面板显示(物料码输入框显隐、label 文字)
+        for (WorkstationPanel p : panels) {
+            p.refreshDisplayFromConfig(config);
+        }
+
+        JOptionPane.showMessageDialog(this,
+                "工艺流程已更新,所有工位已重建",
+                "配置成功", JOptionPane.INFORMATION_MESSAGE);
+    }
+
+    /**
+     * 软重启工作流:重新构建所有工位的流程引擎、重新注入 panel 和过程参数采集器
+     * 不断开 TCP,不销毁设备驱动(IP变化由 showChangeDeviceIpDialog 另行处理)
+     */
+    private void rebuildWorkflows() {
+        log.info("开始重建工作流...");
+
+        // 先停止过程参数上传器并清空采集器
+        if (prodDataUploader != null) {
+            prodDataUploader.stop();
+            prodDataUploader.clearCollectors();
+            prodDataUploader = null;
+        }
+        // 若 features 启用了 prod_params 且 yaml 配置存在,重新创建 uploader
+        if (config.hasProdParams()) {
+            int uploadInterval = config.getProdParamsConfig().getUploadInterval();
+            prodDataUploader = new ProdDataUploader(
+                    config.getServerIp(), config.getHttpPort(), uploadInterval);
+        }
+
+        // 逐个工位重建
+        for (int i = 0; i < engines.size(); i++) {
+            WorkflowEngine engine = engines.get(i);
+            StationContext ctx = contexts.get(i);
+
+            // 停止旧流程
+            engine.stop();
+
+            // 用新配置重建步骤
+            engine.buildFromConfig(config.getWorkflowSteps(), stepFactory);
+
+            // 重新注入 panel(扫码框弹出)
+            if (i < panels.size()) {
+                WorkstationPanel p = panels.get(i);
+                for (com.mes.step.IWorkflowStep step : engine.getSteps()) {
+                    stepFactory.injectPanel(step, p);
+                }
+            }
+
+            // 重新注入过程参数采集器
+            if (prodDataUploader != null && config.hasProdParams()) {
+                ProdDataCollector collector = new ProdDataCollector(config.getProdParamsConfig());
+                for (com.mes.step.IWorkflowStep step : engine.getSteps()) {
+                    if (step instanceof WaitCompleteStep) {
+                        ((WaitCompleteStep) step).setProdDataCollector(collector);
+                    }
+                }
+                prodDataUploader.addCollector(ctx.getStationCode(), collector);
+            }
+
+            // 重置并重新启动
+            engine.reset();
+            engine.start();
+            if (i < panels.size()) panels.get(i).reset();
+            ctx.setStatusMessage("工艺已更新,请扫下一件", 0);
+        }
+
+        // 刷新监控面板
+        if (workflowMonitorPanel != null) {
+            workflowMonitorPanel.setWorkstations(engines, contexts);
+        }
+
+        // 启动新的过程参数上传器
+        if (prodDataUploader != null) {
+            prodDataUploader.start();
+        }
+
+        log.info("工作流重建完成, 步骤数={}", config.getWorkflowSteps().size());
+    }
+
+    /**
+     * 弹出"设备信息显示"对话框
+     * 只允许勾选启用/禁用 + 改 label,寄存器地址不在UI改(留yaml)
+     */
+    private void showDeviceInfoUiDialog() {
+        com.mes.core.ProjectConfigManager pc = com.mes.core.ProjectConfigManager.getInstance();
+        java.util.List<StationConfig.DeviceInfoRow> rows = config.getDeviceInfoRows();
+
+        // 第1行
+        StationConfig.DeviceInfoRow r1 = rows.size() > 0 ? rows.get(0) : null;
+        JCheckBox enableA = new JCheckBox("启用第1行", r1 != null && r1.isEnabled());
+        enableA.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        JTextField labelA = new JTextField(r1 != null && r1.getLabel() != null ? r1.getLabel() : "");
+        labelA.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+
+        // 第2行
+        StationConfig.DeviceInfoRow r2 = rows.size() > 1 ? rows.get(1) : null;
+        JCheckBox enableB = new JCheckBox("启用第2行", r2 != null && r2.isEnabled());
+        enableB.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        JTextField labelB = new JTextField(r2 != null && r2.getLabel() != null ? r2.getLabel() : "");
+        labelB.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+
+        // 物料码输入框显隐
+        JCheckBox showMaterial = new JCheckBox("显示物料码输入框", config.isShowMaterialInput());
+        showMaterial.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+
+        JPanel panel = new JPanel(new java.awt.GridBagLayout());
+        java.awt.GridBagConstraints gbc = new java.awt.GridBagConstraints();
+        gbc.insets = new Insets(4, 4, 4, 4);
+        gbc.anchor = java.awt.GridBagConstraints.WEST;
+        // 第1行
+        gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2;
+        panel.add(enableA, gbc);
+        gbc.gridy = 1; gbc.gridwidth = 1;
+        panel.add(new JLabel("标签:"), gbc);
+        gbc.gridx = 1; gbc.fill = java.awt.GridBagConstraints.HORIZONTAL; gbc.weightx = 1;
+        labelA.setPreferredSize(new Dimension(160, 26));
+        panel.add(labelA, gbc);
+        // 第2行
+        gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 2; gbc.fill = java.awt.GridBagConstraints.NONE; gbc.weightx = 0;
+        panel.add(enableB, gbc);
+        gbc.gridy = 3; gbc.gridwidth = 1;
+        panel.add(new JLabel("标签:"), gbc);
+        gbc.gridx = 1; gbc.fill = java.awt.GridBagConstraints.HORIZONTAL; gbc.weightx = 1;
+        labelB.setPreferredSize(new Dimension(160, 26));
+        panel.add(labelB, gbc);
+        // 提示
+        gbc.gridx = 0; gbc.gridy = 4; gbc.gridwidth = 2; gbc.fill = java.awt.GridBagConstraints.NONE;
+        panel.add(showMaterial, gbc);
+        gbc.gridy = 5;
+        JLabel tip = new JLabel("<html><font color='gray' size='3'>寄存器地址需改 station.yaml</font></html>");
+        panel.add(tip, gbc);
+
+        int result = JOptionPane.showConfirmDialog(this, panel, "设备信息显示",
+                JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
+        if (result != JOptionPane.OK_OPTION) return;
+
+        pc.setDeviceInfoRowsOverride(
+                enableA.isSelected(), labelA.getText(),
+                enableB.isSelected(), labelB.getText(),
+                showMaterial.isSelected());
+
+        // 重新加载 config 让 UI 覆盖生效
+        StationConfig.reload();
+        this.config = StationConfig.getInstance();
+        // 重新应用运行时IP
+        for (StationConfig.DeviceConnection conn : config.getDeviceConnections()) {
+            conn.setIp(pc.getDeviceIp());
+        }
+        // 刷新工位面板显示(启用/禁用行、label)
+        for (WorkstationPanel p : panels) {
+            p.refreshDisplayFromConfig(config);
+        }
+        // 若显示开关改变涉及组件增删,强制重载布局
+        for (WorkstationPanel p : panels) {
+            try { p.reloadLayoutConfig(); } catch (Exception ex) { /* 忽略 */ }
+        }
+
+        JOptionPane.showMessageDialog(this,
+                "设备信息显示配置已更新\n如行数变化布局未立即生效,请重启程序",
+                "配置成功", JOptionPane.INFORMATION_MESSAGE);
     }
 }

+ 87 - 5
src/com/mes/ui/component/WorkstationPanel.java

@@ -29,8 +29,8 @@ public class WorkstationPanel extends JPanel {
     private static final Logger log = LoggerFactory.getLogger(WorkstationPanel.class);
 
     // 工位信息
-    private final String stationCode;
-    private final String stationName;
+    private String stationCode;
+    private String stationName;
     private final int stationIndex;
 
     // 配置
@@ -131,11 +131,12 @@ public class WorkstationPanel extends JPanel {
 
         // 从配置读取显示选项
         if (config != null) {
-            this.showMaterialInput = config.isShowMaterialInput();
+            this.showMaterialInput = config.isEffectiveShowMaterialInput();
             this.showDeviceInfo = config.isShowDeviceInfo();
             this.showDeviceInfo2 = config.isShowDeviceInfo2();
-            if (config.getMaterialLabel() != null) {
-                this.materialLabel = config.getMaterialLabel();
+            String effLabel = config.getEffectiveMaterialLabel();
+            if (effLabel != null && !effLabel.isEmpty()) {
+                this.materialLabel = effLabel;
             }
         }
 
@@ -911,6 +912,87 @@ public class WorkstationPanel extends JPanel {
         return stationCode;
     }
 
+    /**
+     * 运行时更新工位号(修改工位号菜单使用)
+     * 会同步刷新 stationLabel 显示
+     */
+    public void updateStationCode(String newCode) {
+        if (newCode == null || newCode.equals(this.stationCode)) return;
+        this.stationCode = newCode;
+        this.stationName = newCode;
+        if (stationLabel != null) {
+            stationLabel.setText(newCode);
+        }
+    }
+
+    /**
+     * 运行时更新物料码标签(冷板码 ↔ 底护板码切换时调用)
+     */
+    public void updateMaterialLabel(String newLabel) {
+        if (newLabel == null || newLabel.isEmpty()) return;
+        this.materialLabel = newLabel;
+        if (materialLabelUI != null) {
+            materialLabelUI.setText(newLabel + ":");
+        }
+    }
+
+    /**
+     * 重新从 StationConfig 读取 showDeviceInfo/showMaterialInput/label 等显示选项
+     * 用于"设备信息显示""工艺流程配置"菜单改动后的热刷新
+     */
+    public void refreshDisplayFromConfig(StationConfig cfg) {
+        if (cfg == null) return;
+        this.showDeviceInfo = cfg.isShowDeviceInfo();
+        this.showDeviceInfo2 = cfg.isShowDeviceInfo2();
+        this.showMaterialInput = cfg.isEffectiveShowMaterialInput();
+        String effLabel = cfg.getEffectiveMaterialLabel();
+        if (effLabel != null && !effLabel.isEmpty()) {
+            this.materialLabel = effLabel;
+        }
+
+        // 物料码相关组件:按 showMaterialInput 直接控制可见性
+        if (materialLabelUI != null)  materialLabelUI.setVisible(showMaterialInput);
+        if (materialSnInput != null)  materialSnInput.setVisible(showMaterialInput);
+        if (scanMaterialBtn != null)  scanMaterialBtn.setVisible(showMaterialInput);
+        if (unbindMaterialBtn != null) unbindMaterialBtn.setVisible(showMaterialInput);
+        if (showMaterialInput && materialLabelUI != null) {
+            materialLabelUI.setText(this.materialLabel + ":");
+        }
+
+        // 设备信息第1行
+        StationConfig.DeviceInfoRow row0 = cfg.getDeviceInfoRow(0);
+        if (deviceInfoLabel != null) {
+            if (row0 != null && row0.hasLabel()) {
+                deviceInfoLabel.setText(row0.getLabel());
+                deviceInfoLabel.setVisible(showDeviceInfo);
+            } else {
+                deviceInfoLabel.setVisible(false);
+            }
+        }
+        if (presetCountLabel != null)  presetCountLabel.setVisible(showDeviceInfo);
+        if (presetCountField != null)  presetCountField.setVisible(showDeviceInfo);
+        if (finishedCountLabel != null) finishedCountLabel.setVisible(showDeviceInfo);
+        if (finishedCountField != null) finishedCountField.setVisible(showDeviceInfo);
+
+        // 设备信息第2行
+        StationConfig.DeviceInfoRow row1 = cfg.getDeviceInfoRow(1);
+        if (deviceInfoLabel2 != null) {
+            if (row1 != null && row1.hasLabel()) {
+                deviceInfoLabel2.setText(row1.getLabel());
+                deviceInfoLabel2.setVisible(showDeviceInfo2);
+            } else {
+                deviceInfoLabel2.setVisible(false);
+            }
+        }
+        if (presetCountLabel2 != null)  presetCountLabel2.setVisible(showDeviceInfo2);
+        if (presetCountField2 != null)  presetCountField2.setVisible(showDeviceInfo2);
+        if (finishedCountLabel2 != null) finishedCountLabel2.setVisible(showDeviceInfo2);
+        if (finishedCountField2 != null) finishedCountField2.setVisible(showDeviceInfo2);
+
+        revalidate();
+        repaint();
+    }
+
     public String getStationName() {
         return stationName;
     }

+ 91 - 89
src/resources/config/station.yaml

@@ -1,133 +1,135 @@
 # 默认工位配置
 
-#单工位
+# 单工位
 station:
   mode: single
-  name: 液冷板安装+激光点固
+  name: 正面装配
   stations:
-    - code: OP150C
+    - code: OP150A
   line_sn: XT
 
-##双工位
-#station:
-#  mode: single
-#  name: 总成正面装配
-#  stations:
-#    - code: OP140A
-#  line_sn: XT
-
 server:
-#  ip: 127.0.0.1
-  ip: 192.168.110.99
+  ip: 192.168.112.99
   tcp_port: 3000
   http_port: 8980
   heart_beat_cycle: 60
 
 workflow:
-  #手动模式
-#  submit_mode: manual
-#  自动模式
+  # 提交模式:manual=手动点OK/NG,auto=自动提交
   submit_mode: auto
 
+  # 功能开关(yaml为默认值,运行时可在"设置→工艺流程配置"勾选,写入config/project_config.json覆盖)
+  features:
+    cold_plate:    true   # 扫+校验冷板码
+    bottom_plate:  false  # 扫+校验底护板码
+    riveting:      true   # 拉铆流程(心跳+计数+等待完成+停心跳)
+    prod_params:   true   # 拉铆过程参数采集(device.prod_params 生效状态受此开关控制)
+
   steps:
-#    - id: read_device_data
-#      name: 读取设备数据
     - id: scan_product
       name: 扫描工件码
+      # 前缀由"项目"决定(设置→切换项目),此处的 prefix 仅作 fallback
       config:
-        prefix: "+KB24"  # 工件码前缀校验
+        prefix: "+KB24"
+
     - id: check_quality
       name: 质量检查
-      # 质量检查用的工艺号  如果在bind_material则是物料绑定用的工艺号不填有默认值
-      craft: "100000"
-    - id: scan_material
-      name: 扫描冷板码
-      config:
-        label: 冷板码
-        prefix: "+KA94"  # 冷板码前缀校验
-    - id: validate_material
-      name: 校验冷板码
-      config:
-        craft: "400004"  # 冷板校验用的工艺号
-
-#    - id: scan_material
-#      name: 扫描底护板码
-#      config:
-#        label: 底护板码
-#        prefix: "+KB77"  # 底护板码前缀校验
-#    - id: validate_material
-#      name: 校验底护板码
-#      config:
-#        craft: "400004"
-
-#    - id: start_heartbeat
-#
-#    - id: reset_device_count
-#      name: 重置设备计数
-#      config:
-#        address: 8
-#        value: 1
-#        clear_addresses:
-#          - address: 66
-#            value: 0
-#          - address: 68
-#            value: 0
-#          - address: 70
-#            value: 0
-#
-#    - id: wait_device_complete
-#    - id: stop_heartbeat
+      craft: "100000"   # 质量检查工艺号
+
+    # ========== 冷板(feature: cold_plate) ==========
+    - feature: cold_plate
+      steps:
+        - id: scan_material
+          name: 扫描冷板码
+          config:
+            label: 冷板码
+            prefix: "+KA94"   # fallback,实际前缀由项目决定
+        - id: validate_material
+          name: 校验冷板码
+          config:
+            craft: "400004"
+
+    # ========== 底护板(feature: bottom_plate) ==========
+    - feature: bottom_plate
+      steps:
+        - id: scan_material
+          name: 扫描底护板码
+          config:
+            label: 底护板码
+            prefix: "+KB77"   # fallback,实际前缀由项目决定
+        - id: validate_material
+          name: 校验底护板码
+          config:
+            craft: "400004"
+
+    # ========== 拉铆(feature: riveting) ==========
+    - feature: riveting
+      steps:
+        - id: start_heartbeat
+          name: 启动心跳
+        - id: reset_device_count
+          name: 重置设备计数
+          config:
+            address: 8
+            value: 1
+            clear_addresses:
+              - { address: 66, value: 0 }
+              - { address: 68, value: 0 }
+              - { address: 70, value: 0 }
+        - id: wait_device_complete
+          name: 等待拉铆完成
+        - id: stop_heartbeat
+          name: 停止心跳
 
     - id: upload_result
       name: 上传结果
-#      craft: "500002"   # A枪用500001,B枪的包改成500002
+
     - id: reset_station
       name: 重置工位
 
 device:
-  enabled: false
+  enabled: true
   type: modbus_tcp
   connect_timeout: 3000
 
   connections:
     - station_index: 0
-      ip: 192.168.0.6
+      ip: 192.168.0.6     # 运行时可在"设置→修改拉铆设备IP"修改
       port: 502
-#    - station_index: 1
-#      ip: 192.168.0.16
-#      port: 502
-  timeout_enabled: true #预留但未实现的配置项
+
+  timeout_enabled: true
   heartbeat_address: 4160
   ready_signal_address: 8
+
+  # 心跳配置
+  heartbeat:
+    enabled: true
+    address: 4160          # 心跳值寄存器(MES 写入 1/2/3 循环)
+    interval: 500          # 心跳周期(ms)
+    timeout_address: 4170  # 心跳超时阈值寄存器
+    timeout_value: 10      # 超时阈值(单位100ms,10=1秒)
+
   complete_condition:
     type: count
 
-  # 拉铆过程参数采集配置(从PLC读取,通过HTTP上传到MES)
+  # 拉铆过程参数采集(enabled 最终由 features.prod_params 决定
   prod_params:
-    enabled: false
-    # PLC寄存器地址(Int32,读取后除以1000得到实际值)
-    fout_address: 4112    # 力输出值 F-out
-    sout_address: 4120    # 行程输出值 S-out
-    fmin_address: 4116    # 力最小值 F-min
-    smin_address: 4126    # 行程最小值 S-min
-    fmax_address: 4114    # 力最大值 F-max
-    smax_address: 4124    # 行程最大值 S-max
-    qty_address: 4         # 数量寄存器地址(Int16
-    upload_interval: 60   # 上传间隔(秒)
+    enabled: true
+    fout_address: 4112   # 力输出值 F-out
+    sout_address: 4120   # 行程输出值 S-out
+    fmin_address: 4116   # 力最小值 F-min
+    smin_address: 4126   # 行程最小值 S-min
+    fmax_address: 4114   # 力最大值 F-max
+    smax_address: 4124   # 行程最大值 S-max
+    qty_address: 4       # 数量寄存器地址(Int16)
+    upload_interval: 60  # 上传间隔(秒
+
 ui:
-#  设备信息行配置(最多2行)
-#  label: 可选前置标签,不配就不显示
-#  preset_address: 预设数量寄存器地址
-#  finished_address: 完成数量寄存器地址
+  # 设备信息行(最多2行)
   device_info_rows:
     - label: "A"
-#      connection_index: 0
-#      preset_address: 4534
-#      finished_address: 66
-#    - label: "B"
-#      connection_index: 1
-#      preset_address: 4534
-#      finished_address: 66
-#  绑定物料
-  show_material_input: true
+      connection_index: 0
+      preset_address: 4534
+      finished_address: 66
+  show_material_input: false
   material_label: 冷板码