wangxichen 2 päivää sitten
vanhempi
commit
72f07be5f2

BIN
classes/com/mes/core/ProjectConfigManager$Line.class


BIN
classes/com/mes/core/ProjectConfigManager$Listener.class


BIN
classes/com/mes/core/ProjectConfigManager$Product.class


BIN
classes/com/mes/core/ProjectConfigManager.class


+ 5 - 5
config/project_config.json

@@ -1,7 +1,7 @@
 {
 	"currentLineId":"P3",
 	"currentProductId":"AY7-520",
-	"deviceIp":"192.168.0.6",
+	"deviceIp":"192.168.0.8",
 	"stationCodeOverride":"OP150A",
 	"lineSnOverride":"XT",
 	"serverIpOverride":"192.168.112.99",
@@ -9,9 +9,9 @@
 	"uiOverride":{
 		"deviceRow1Enabled":true,
 		"deviceRow1Label":"M5",
-		"deviceRow2Enabled":true,
-		"deviceRow2Label":"M6",
-		"showMaterialInput":false
+		"deviceRow2Enabled":false,
+		"showMaterialInput":false,
+		"prodParamsType":"B"
 	},
 	"featuresOverride":{
 		"cold_plate":false,
@@ -21,5 +21,5 @@
 		"quality_submit":false,
 		"check_quality":false
 	},
-	"lastModified":1784086182537
+	"lastModified":1784099583082
 }

+ 5 - 0
config/ui_prefs.json

@@ -0,0 +1,5 @@
+{
+	"alwaysShow":[
+		"manual_submit"
+	]
+}

+ 285 - 30
src/com/mes/core/ProjectConfigManager.java

@@ -50,22 +50,22 @@ public class ProjectConfigManager {
 
     // ========== 产品号定义 ==========
     public static final class Product {
-        private final String id;                 // P02-1/AY7-610/AY7-520/P04-1/650
-        private final String productPrefix;       // 工件码(框架码) 前缀
-        private final String coldPlatePrefix;     // 冷板码 前缀
-        private final String bottomPlatePrefix;   // 底护板码 前缀
+        private final String id;                      // P02-1/AY7-610/AY7-520/P04-1/650
+        private final String[] productPrefixes;       // 工件码(框架码) 前缀白名单
+        private final String[] coldPlatePrefixes;     // 冷板码 前缀白名单
+        private final String[] bottomPlatePrefixes;   // 底护板码 前缀白名单
 
-        public Product(String id, String productPrefix, String coldPlatePrefix, String bottomPlatePrefix) {
+        public Product(String id, String[] productPrefixes, String[] coldPlatePrefixes, String[] bottomPlatePrefixes) {
             this.id = id;
-            this.productPrefix = productPrefix;
-            this.coldPlatePrefix = coldPlatePrefix;
-            this.bottomPlatePrefix = bottomPlatePrefix;
+            this.productPrefixes = productPrefixes;
+            this.coldPlatePrefixes = coldPlatePrefixes;
+            this.bottomPlatePrefixes = bottomPlatePrefixes;
         }
 
         public String getId() { return id; }
-        public String getProductPrefix() { return productPrefix; }
-        public String getColdPlatePrefix() { return coldPlatePrefix; }
-        public String getBottomPlatePrefix() { return bottomPlatePrefix; }
+        public String[] getProductPrefixes() { return productPrefixes; }
+        public String[] getColdPlatePrefixes() { return coldPlatePrefixes; }
+        public String[] getBottomPlatePrefixes() { return bottomPlatePrefixes; }
 
         /** 供UI下拉显示 */
         public String getDisplay() { return id; }
@@ -81,11 +81,11 @@ public class ProjectConfigManager {
 
     // 内置产品号列表(顺序即 UI 展示顺序)
     private static final List<Product> PRODUCTS = Collections.unmodifiableList(Arrays.asList(
-            new Product("P02-1",   "+KA99",   "+KB004",  "+KB77"),
-            new Product("AY7-610", "+KB24",   "+KA94",   "+KB77"),
-            new Product("AY7-520", "+KB23",   "+KB004",  "+KB77"),
-            new Product("P04-1",   "+KA93",   "+KA94",   "+KB77"),
-            new Product("650",     "+KA64IS", "+KA64IG", "+KB78")
+            new Product("P02-1",   new String[]{"+KA99", "+KC14", "+KB98"},  new String[]{"+KB004", "2510"},  new String[]{"+KB77"}),
+            new Product("AY7-610", new String[]{"+KB24"},                     new String[]{"+KA94"},           new String[]{"+KB77"}),
+            new Product("AY7-520", new String[]{"+KB23"},                     new String[]{"+KB004"},          new String[]{"+KB77"}),
+            new Product("P04-1",   new String[]{"+KA93"},                     new String[]{"+KA94"},           new String[]{"+KB77"}),
+            new Product("650",     new String[]{"+KA64IS"},                   new String[]{"+KA64IG"},         new String[]{"+KB78"})
     ));
 
     // 默认值
@@ -138,6 +138,10 @@ public class ProjectConfigManager {
     private Boolean showMaterialInputOverride;
     // 拉铆过程参数上传类型 (A/B/C/D),默认 A
     private String prodParamsType = "A";
+    // 前缀白名单运行时覆盖(优先级高于产品定义)
+    private String[] productPrefixOverride;
+    private String[] coldPlatePrefixOverride;
+    private String[] bottomPlatePrefixOverride;
     private final List<Listener> listeners = new ArrayList<>();
 
     private ProjectConfigManager() {
@@ -225,8 +229,34 @@ public class ProjectConfigManager {
                 String pt = trimToNull(uiObj.getString("prodParamsType"));
                 if (pt != null) prodParamsType = pt.toUpperCase();
             }
-            log.info("[ProjectConfig] 加载成功: line={}, product={}, ip={}, station={}, lineSn={}, features={}",
-                    currentLineId, currentProductId, deviceIp, stationCodeOverride, lineSnOverride, featuresOverride);
+            // 前缀白名单覆盖
+            JSONObject prefixObj = json.getJSONObject("prefixOverride");
+            if (prefixObj != null) {
+                String productPrefixStr = trimToNull(prefixObj.getString("product"));
+                if (productPrefixStr != null) {
+                    productPrefixOverride = productPrefixStr.split(",");
+                    for (int i = 0; i < productPrefixOverride.length; i++) {
+                        productPrefixOverride[i] = productPrefixOverride[i].trim();
+                    }
+                }
+                String coldPlatePrefixStr = trimToNull(prefixObj.getString("coldPlate"));
+                if (coldPlatePrefixStr != null) {
+                    coldPlatePrefixOverride = coldPlatePrefixStr.split(",");
+                    for (int i = 0; i < coldPlatePrefixOverride.length; i++) {
+                        coldPlatePrefixOverride[i] = coldPlatePrefixOverride[i].trim();
+                    }
+                }
+                String bottomPlatePrefixStr = trimToNull(prefixObj.getString("bottomPlate"));
+                if (bottomPlatePrefixStr != null) {
+                    bottomPlatePrefixOverride = bottomPlatePrefixStr.split(",");
+                    for (int i = 0; i < bottomPlatePrefixOverride.length; i++) {
+                        bottomPlatePrefixOverride[i] = bottomPlatePrefixOverride[i].trim();
+                    }
+                }
+            }
+            log.info("[ProjectConfig] 加载成功: line={}, product={}, ip={}, station={}, lineSn={}, features={}, prefixOverride={}",
+                    currentLineId, currentProductId, deviceIp, stationCodeOverride, lineSnOverride, featuresOverride,
+                    (productPrefixOverride != null || coldPlatePrefixOverride != null || bottomPlatePrefixOverride != null));
         } catch (Exception e) {
             log.error("[ProjectConfig] 加载失败: {}", e.getMessage(), e);
         }
@@ -256,6 +286,20 @@ public class ProjectConfigManager {
             if (!featuresOverride.isEmpty()) {
                 root.put("featuresOverride", featuresOverride);
             }
+            // 前缀白名单覆盖
+            if (productPrefixOverride != null || coldPlatePrefixOverride != null || bottomPlatePrefixOverride != null) {
+                JSONObject prefixObj = new JSONObject();
+                if (productPrefixOverride != null) {
+                    prefixObj.put("product", String.join(",", productPrefixOverride));
+                }
+                if (coldPlatePrefixOverride != null) {
+                    prefixObj.put("coldPlate", String.join(",", coldPlatePrefixOverride));
+                }
+                if (bottomPlatePrefixOverride != null) {
+                    prefixObj.put("bottomPlate", String.join(",", bottomPlatePrefixOverride));
+                }
+                root.put("prefixOverride", prefixObj);
+            }
             root.put("lastModified", System.currentTimeMillis());
             String content = JSON.toJSONString(root, JSONWriter.Feature.PrettyFormat);
             Files.write(configPath, content.getBytes(StandardCharsets.UTF_8));
@@ -313,33 +357,147 @@ public class ProjectConfigManager {
 
     // ========== 前缀快捷方法 ==========
 
-    /** 工件码(框架码)前缀,若当前产品找不到则返回null */
-    public String getProductPrefix() {
+    /** 工件码(框架码)前缀数组,优先返回运行时覆盖值,其次返回产品定义 */
+    public String[] getProductPrefixes() {
+        if (productPrefixOverride != null) return productPrefixOverride;
         Product p = getCurrentProduct();
-        return p != null ? p.getProductPrefix() : null;
+        return p != null ? p.getProductPrefixes() : null;
     }
 
-    /** 冷板码前缀 */
-    public String getColdPlatePrefix() {
+    /** 冷板码前缀数组,优先返回运行时覆盖值 */
+    public String[] getColdPlatePrefixes() {
+        if (coldPlatePrefixOverride != null) return coldPlatePrefixOverride;
         Product p = getCurrentProduct();
-        return p != null ? p.getColdPlatePrefix() : null;
+        return p != null ? p.getColdPlatePrefixes() : null;
     }
 
-    /** 底护板码前缀 */
-    public String getBottomPlatePrefix() {
+    /** 底护板码前缀数组,优先返回运行时覆盖值 */
+    public String[] getBottomPlatePrefixes() {
+        if (bottomPlatePrefixOverride != null) return bottomPlatePrefixOverride;
         Product p = getCurrentProduct();
-        return p != null ? p.getBottomPlatePrefix() : null;
+        return p != null ? p.getBottomPlatePrefixes() : null;
+    }
+
+    /**
+     * 校验工件码是否匹配任一允许的前缀
+     */
+    public boolean matchProductPrefix(String barcode) {
+        if (barcode == null || barcode.isEmpty()) return false;
+        String[] prefixes = getProductPrefixes();
+        if (prefixes == null || prefixes.length == 0) return true;
+        for (String prefix : prefixes) {
+            if (prefix != null && barcode.startsWith(prefix.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 拼接所有允许的工件码前缀,用于错误提示
+     */
+    public String getProductPrefixHint() {
+        String[] prefixes = getProductPrefixes();
+        if (prefixes == null || prefixes.length == 0) return "";
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < prefixes.length; i++) {
+            if (i > 0) sb.append("/");
+            sb.append(prefixes[i].trim());
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 校验冷板码是否匹配任一允许的前缀
+     */
+    public boolean matchColdPlatePrefix(String barcode) {
+        if (barcode == null || barcode.isEmpty()) return false;
+        String[] prefixes = getColdPlatePrefixes();
+        if (prefixes == null || prefixes.length == 0) return true;
+        for (String prefix : prefixes) {
+            if (prefix != null && barcode.startsWith(prefix.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 拼接所有允许的冷板码前缀,用于错误提示
+     */
+    public String getColdPlatePrefixHint() {
+        String[] prefixes = getColdPlatePrefixes();
+        if (prefixes == null || prefixes.length == 0) return "";
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < prefixes.length; i++) {
+            if (i > 0) sb.append("/");
+            sb.append(prefixes[i].trim());
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 校验底护板码是否匹配任一允许的前缀
+     */
+    public boolean matchBottomPlatePrefix(String barcode) {
+        if (barcode == null || barcode.isEmpty()) return false;
+        String[] prefixes = getBottomPlatePrefixes();
+        if (prefixes == null || prefixes.length == 0) return true;
+        for (String prefix : prefixes) {
+            if (prefix != null && barcode.startsWith(prefix.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 拼接所有允许的底护板码前缀,用于错误提示
+     */
+    public String getBottomPlatePrefixHint() {
+        String[] prefixes = getBottomPlatePrefixes();
+        if (prefixes == null || prefixes.length == 0) return "";
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < prefixes.length; i++) {
+            if (i > 0) sb.append("/");
+            sb.append(prefixes[i].trim());
+        }
+        return sb.toString();
     }
 
     /**
      * 根据物料标签获取前缀(供 ScanMaterialStep 区分)
      * 支持"冷板码""底护板码"两种标签
+     * @deprecated 使用 matchMaterialPrefix 和 getMaterialPrefixHint 替代
      */
+    @Deprecated
     public String getMaterialPrefixByLabel(String label) {
         if (label == null) return null;
-        if (label.contains("冷板")) return getColdPlatePrefix();
-        if (label.contains("底护板")) return getBottomPlatePrefix();
-        return null;
+        String[] prefixes = null;
+        if (label.contains("冷板")) prefixes = getColdPlatePrefixes();
+        else if (label.contains("底护板")) prefixes = getBottomPlatePrefixes();
+        // 返回第一个前缀(兼容旧逻辑)
+        return (prefixes != null && prefixes.length > 0) ? prefixes[0] : null;
+    }
+
+    /**
+     * 根据物料标签校验前缀(白名单匹配)
+     */
+    public boolean matchMaterialPrefix(String barcode, String label) {
+        if (label == null) return true;
+        if (label.contains("冷板")) return matchColdPlatePrefix(barcode);
+        if (label.contains("底护板")) return matchBottomPlatePrefix(barcode);
+        return true;
+    }
+
+    /**
+     * 根据物料标签获取前缀提示
+     */
+    public String getMaterialPrefixHint(String label) {
+        if (label == null) return "";
+        if (label.contains("冷板")) return getColdPlatePrefixHint();
+        if (label.contains("底护板")) return getBottomPlatePrefixHint();
+        return "";
     }
 
     // ========== 切换产线位置 ==========
@@ -428,6 +586,96 @@ public class ProjectConfigManager {
     public String getLineSnOverride() { return lineSnOverride; }
     public String getServerIpOverride() { return serverIpOverride; }
 
+    // ========== 前缀白名单覆盖 ==========
+
+    /**
+     * 设置工件码前缀白名单覆盖
+     * @param prefixes 逗号分隔的前缀字符串,如 "+KA99,+KC14,+KB98";null 或空字符串表示清除覆盖
+     */
+    public void setProductPrefixOverride(String prefixes) {
+        if (prefixes == null || prefixes.trim().isEmpty()) {
+            productPrefixOverride = null;
+        } else {
+            productPrefixOverride = prefixes.split(",");
+            for (int i = 0; i < productPrefixOverride.length; i++) {
+                productPrefixOverride[i] = productPrefixOverride[i].trim();
+            }
+        }
+        save();
+        log.info("[ProjectConfig] 工件码前缀覆盖: {}", prefixes);
+        firePrefixChanged();
+    }
+
+    /**
+     * 设置冷板码前缀白名单覆盖
+     */
+    public void setColdPlatePrefixOverride(String prefixes) {
+        if (prefixes == null || prefixes.trim().isEmpty()) {
+            coldPlatePrefixOverride = null;
+        } else {
+            coldPlatePrefixOverride = prefixes.split(",");
+            for (int i = 0; i < coldPlatePrefixOverride.length; i++) {
+                coldPlatePrefixOverride[i] = coldPlatePrefixOverride[i].trim();
+            }
+        }
+        save();
+        log.info("[ProjectConfig] 冷板码前缀覆盖: {}", prefixes);
+        firePrefixChanged();
+    }
+
+    /**
+     * 设置底护板码前缀白名单覆盖
+     */
+    public void setBottomPlatePrefixOverride(String prefixes) {
+        if (prefixes == null || prefixes.trim().isEmpty()) {
+            bottomPlatePrefixOverride = null;
+        } else {
+            bottomPlatePrefixOverride = prefixes.split(",");
+            for (int i = 0; i < bottomPlatePrefixOverride.length; i++) {
+                bottomPlatePrefixOverride[i] = bottomPlatePrefixOverride[i].trim();
+            }
+        }
+        save();
+        log.info("[ProjectConfig] 底护板码前缀覆盖: {}", prefixes);
+        firePrefixChanged();
+    }
+
+    /**
+     * 获取工件码前缀覆盖的字符串表示(用于UI显示)
+     */
+    public String getProductPrefixOverrideString() {
+        if (productPrefixOverride == null) return null;
+        return String.join(",", productPrefixOverride);
+    }
+
+    /**
+     * 获取冷板码前缀覆盖的字符串表示
+     */
+    public String getColdPlatePrefixOverrideString() {
+        if (coldPlatePrefixOverride == null) return null;
+        return String.join(",", coldPlatePrefixOverride);
+    }
+
+    /**
+     * 获取底护板码前缀覆盖的字符串表示
+     */
+    public String getBottomPlatePrefixOverrideString() {
+        if (bottomPlatePrefixOverride == null) return null;
+        return String.join(",", bottomPlatePrefixOverride);
+    }
+
+    /**
+     * 清除所有前缀覆盖,恢复使用产品定义
+     */
+    public void clearPrefixOverrides() {
+        productPrefixOverride = null;
+        coldPlatePrefixOverride = null;
+        bottomPlatePrefixOverride = null;
+        save();
+        log.info("[ProjectConfig] 已清除所有前缀覆盖");
+        firePrefixChanged();
+    }
+
     /**
      * 设置服务器(MES)IP 覆盖
      */
@@ -545,6 +793,7 @@ public class ProjectConfigManager {
         default void onFeaturesChanged() {}
         default void onStationChanged() {}
         default void onUiChanged() {}
+        default void onPrefixChanged() {}
     }
 
     public void addListener(Listener l) {
@@ -596,4 +845,10 @@ public class ProjectConfigManager {
             try { l.onUiChanged(); } catch (Exception e) { log.warn("listener error", e); }
         }
     }
+
+    private void firePrefixChanged() {
+        for (Listener l : new ArrayList<>(listeners)) {
+            try { l.onPrefixChanged(); } catch (Exception e) { log.warn("listener error", e); }
+        }
+    }
 }

+ 30 - 17
src/com/mes/step/ScanMaterialStep.java

@@ -48,34 +48,47 @@ public class ScanMaterialStep extends AbstractStep {
         if (context.getMaterialSn() == null || context.getMaterialSn().trim().isEmpty()) {
             log.info("[{}] 自动弹出{}扫码框", context.getStationCode(), label);
             context.setStatusMessage("请扫描" + label, 1);
-            
+
             // 弹出扫码框
             if (panel != null) {
                 panel.requestMaterialScan();
             }
-            
+
             return false;  // 等待用户扫码
         }
 
         // 用户扫码后:验证物料码
         String materialSn = context.getMaterialSn().trim();
 
-        // 前缀校验:优先按 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();
+        // 白名单前缀校验:优先使用项目配置的前缀白名单
+        ProjectConfigManager pm = ProjectConfigManager.getInstance();
+        if (!pm.matchMaterialPrefix(materialSn, label)) {
+            // 未匹配到任何前缀,fallback 检查 yaml 配置
+            if (prefix != null && !prefix.isEmpty() && !materialSn.startsWith(prefix)) {
+                log.warn("[{}] {}前缀错误: {},允许的前缀: {} (fallback: {})",
+                         context.getStationCode(), label, materialSn, pm.getMaterialPrefixHint(label), prefix);
+                context.setStatusMessage(label + "前缀错误,必须以 " + pm.getMaterialPrefixHint(label) + " 开头", -1);
+                context.setMaterialSn(null);
+
+                // 格式错误,重新弹出扫码框
+                if (panel != null) {
+                    panel.requestMaterialScan();
+                }
+
+                return false;
+            } else if (prefix == null || prefix.isEmpty()) {
+                log.warn("[{}] {}前缀错误: {},允许的前缀: {}",
+                         context.getStationCode(), label, materialSn, pm.getMaterialPrefixHint(label));
+                context.setStatusMessage(label + "前缀错误,必须以 " + pm.getMaterialPrefixHint(label) + " 开头", -1);
+                context.setMaterialSn(null);
+
+                // 格式错误,重新弹出扫码框
+                if (panel != null) {
+                    panel.requestMaterialScan();
+                }
+
+                return false;
             }
-
-            return false;
         }
 
         log.info("[{}] 扫描{}完成: {}", context.getStationCode(), label, materialSn);

+ 18 - 11
src/com/mes/step/ScanProductStep.java

@@ -28,7 +28,7 @@ public class ScanProductStep extends AbstractStep {
     @Override
     public boolean execute(StationContext context) {
         String productSn = context.getProductSn();
-        
+
         if (productSn == null || productSn.trim().isEmpty()) {
             log.warn("[{}] 工件码为空,等待用户扫码", context.getStationCode());
             context.setStatusMessage("请扫描工件码", 1);
@@ -42,16 +42,23 @@ public class ScanProductStep extends AbstractStep {
             log.info("[{}] 36位码处理: {} -> {}", context.getStationCode(), productSn, processedSn);
         }
 
-        // 前缀校验:优先读项目配置,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;
+        // 白名单前缀校验:优先使用项目配置的前缀白名单
+        ProjectConfigManager pm = ProjectConfigManager.getInstance();
+        if (!pm.matchProductPrefix(processedSn)) {
+            // 未匹配到任何前缀,fallback 检查 yaml 配置
+            if (prefix != null && !prefix.isEmpty() && !processedSn.startsWith(prefix)) {
+                log.warn("[{}] 工件码前缀错误: {},允许的前缀: {} (fallback: {})",
+                         context.getStationCode(), processedSn, pm.getProductPrefixHint(), prefix);
+                context.setStatusMessage("工件码前缀错误,必须以 " + pm.getProductPrefixHint() + " 开头", -1);
+                context.setProductSn(null);
+                return false;
+            } else if (prefix == null || prefix.isEmpty()) {
+                log.warn("[{}] 工件码前缀错误: {},允许的前缀: {}",
+                         context.getStationCode(), processedSn, pm.getProductPrefixHint());
+                context.setStatusMessage("工件码前缀错误,必须以 " + pm.getProductPrefixHint() + " 开头", -1);
+                context.setProductSn(null);
+                return false;
+            }
         }
 
         // 保存处理后的工件码

+ 30 - 2
src/com/mes/ui/MainFrame.java

@@ -248,6 +248,13 @@ public class MainFrame extends JFrame {
         switchProductItem.addActionListener(e -> showSwitchProductDialog());
         projectDeviceMenu.add(switchProductItem);
 
+        JMenuItem configurePrefixItem = new JMenuItem("配置前缀");
+        configurePrefixItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/menu_setting.png"))));
+        configurePrefixItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
+        configurePrefixItem.setToolTipText("配置工件码、冷板码、底护板码的前缀白名单");
+        configurePrefixItem.addActionListener(e -> showConfigurePrefixDialog());
+        projectDeviceMenu.add(configurePrefixItem);
+
         switchStationItem = new JMenuItem();
         switchStationItem.setIcon(new ImageIcon(Objects.requireNonNull(getClass().getResource("/resources/image/bg/menu_setting.png"))));
         switchStationItem.setFont(new Font("微软雅黑", Font.PLAIN, 20));
@@ -1356,7 +1363,9 @@ public class MainFrame extends JFrame {
             projectMenu.setToolTipText(String.format(
                     "<html>产线位置:%s<br>产品号:%s<br>工件码前缀:%s<br>冷板码前缀:%s<br>底护板码前缀:%s</html>",
                     line.getDisplay(), product.getDisplay(),
-                    product.getProductPrefix(), product.getColdPlatePrefix(), product.getBottomPlatePrefix()));
+                    String.join("/", product.getProductPrefixes()),
+                    String.join("/", product.getColdPlatePrefixes()),
+                    String.join("/", product.getBottomPlatePrefixes())));
         }
     }
 
@@ -1431,7 +1440,10 @@ public class MainFrame extends JFrame {
         for (int i = 0; i < products.size(); i++) {
             com.mes.core.ProjectConfigManager.Product p = products.get(i);
             options[i] = String.format("%s  (工件:%s 冷板:%s 底护板:%s)",
-                    p.getDisplay(), p.getProductPrefix(), p.getColdPlatePrefix(), p.getBottomPlatePrefix());
+                    p.getDisplay(),
+                    String.join("/", p.getProductPrefixes()),
+                    String.join("/", p.getColdPlatePrefixes()),
+                    String.join("/", p.getBottomPlatePrefixes()));
             if (p.getId().equalsIgnoreCase(pc.getCurrentProductId())) {
                 currentIndex = i;
             }
@@ -1472,6 +1484,22 @@ public class MainFrame extends JFrame {
     }
 
     /**
+     * 弹出前缀配置对话框
+     */
+    private void showConfigurePrefixDialog() {
+        PrefixConfigDialog dialog = new PrefixConfigDialog(this);
+        dialog.setVisible(true);
+
+        // 如果用户保存了配置,重置所有工位(前缀立即生效)
+        if (dialog.isSaved()) {
+            for (int i = 0; i < panels.size(); i++) {
+                panels.get(i).reset();
+            }
+            updateProjectDisplay();
+        }
+    }
+
+    /**
      * 弹出修改拉铆设备IP对话框
      */
     private void showChangeDeviceIpDialog() {

+ 229 - 0
src/com/mes/ui/PrefixConfigDialog.java

@@ -0,0 +1,229 @@
+package com.mes.ui;
+
+import com.mes.core.ProjectConfigManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * 前缀配置对话框
+ * 参考 mesclient-op150 的 SettingDialog 实现
+ * 支持运行时修改工件码、冷板码、底护板码的前缀白名单
+ */
+public class PrefixConfigDialog extends JDialog {
+    private static final Logger log = LoggerFactory.getLogger(PrefixConfigDialog.class);
+
+    private JTextField productPrefixField;
+    private JTextField coldPlatePrefixField;
+    private JTextField bottomPlatePrefixField;
+    private boolean saved = false;
+
+    public PrefixConfigDialog(Frame owner) {
+        super(owner, "前缀白名单配置", true);
+        initUI();
+    }
+
+    private void initUI() {
+        setSize(600, 280);
+        setLocationRelativeTo(getOwner());
+        setResizable(false);
+
+        JPanel mainPanel = new JPanel();
+        mainPanel.setLayout(new BorderLayout(10, 10));
+        mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20));
+
+        // 顶部说明
+        JLabel tipLabel = new JLabel("<html>修改前缀白名单(用逗号分隔多个前缀)<br>示例:+KA99,+KC14,+KB98</html>");
+        tipLabel.setFont(new Font("微软雅黑", Font.PLAIN, 14));
+        tipLabel.setForeground(Color.GRAY);
+        mainPanel.add(tipLabel, BorderLayout.NORTH);
+
+        // 中间面板 - 配置项
+        JPanel centerPanel = new JPanel(new GridLayout(3, 2, 10, 10));
+
+        JLabel productPrefixLabel = new JLabel("工件码前缀:");
+        productPrefixLabel.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        centerPanel.add(productPrefixLabel);
+
+        // 读取当前配置(优先显示覆盖值,没有则显示产品定义)
+        ProjectConfigManager pm = ProjectConfigManager.getInstance();
+        String currentProductPrefix = pm.getProductPrefixOverrideString();
+        if (currentProductPrefix == null) {
+            String[] prefixes = pm.getProductPrefixes();
+            currentProductPrefix = prefixes != null ? String.join(",", prefixes) : "";
+        }
+        productPrefixField = new JTextField(currentProductPrefix);
+        productPrefixField.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        centerPanel.add(productPrefixField);
+
+        JLabel coldPlatePrefixLabel = new JLabel("冷板码前缀:");
+        coldPlatePrefixLabel.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        centerPanel.add(coldPlatePrefixLabel);
+
+        String currentColdPlatePrefix = pm.getColdPlatePrefixOverrideString();
+        if (currentColdPlatePrefix == null) {
+            String[] prefixes = pm.getColdPlatePrefixes();
+            currentColdPlatePrefix = prefixes != null ? String.join(",", prefixes) : "";
+        }
+        coldPlatePrefixField = new JTextField(currentColdPlatePrefix);
+        coldPlatePrefixField.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        centerPanel.add(coldPlatePrefixField);
+
+        JLabel bottomPlatePrefixLabel = new JLabel("底护板码前缀:");
+        bottomPlatePrefixLabel.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        centerPanel.add(bottomPlatePrefixLabel);
+
+        String currentBottomPlatePrefix = pm.getBottomPlatePrefixOverrideString();
+        if (currentBottomPlatePrefix == null) {
+            String[] prefixes = pm.getBottomPlatePrefixes();
+            currentBottomPlatePrefix = prefixes != null ? String.join(",", prefixes) : "";
+        }
+        bottomPlatePrefixField = new JTextField(currentBottomPlatePrefix);
+        bottomPlatePrefixField.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        centerPanel.add(bottomPlatePrefixField);
+
+        mainPanel.add(centerPanel, BorderLayout.CENTER);
+
+        // 底部按钮面板
+        JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 20, 10));
+
+        JButton saveButton = new JButton("保存");
+        saveButton.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        saveButton.addActionListener(e -> saveConfig());
+        buttonPanel.add(saveButton);
+
+        JButton resetButton = new JButton("恢复默认");
+        resetButton.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        resetButton.setToolTipText("清除覆盖配置,恢复使用当前产品的默认前缀");
+        resetButton.addActionListener(e -> resetToDefault());
+        buttonPanel.add(resetButton);
+
+        JButton cancelButton = new JButton("取消");
+        cancelButton.setFont(new Font("微软雅黑", Font.PLAIN, 16));
+        cancelButton.addActionListener(e -> dispose());
+        buttonPanel.add(cancelButton);
+
+        mainPanel.add(buttonPanel, BorderLayout.SOUTH);
+
+        setContentPane(mainPanel);
+    }
+
+    private void saveConfig() {
+        String productPrefix = productPrefixField.getText().trim();
+        if (productPrefix.isEmpty()) {
+            JOptionPane.showMessageDialog(this,
+                "工件码前缀不能为空",
+                "输入错误",
+                JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+
+        String coldPlatePrefix = coldPlatePrefixField.getText().trim();
+        if (coldPlatePrefix.isEmpty()) {
+            JOptionPane.showMessageDialog(this,
+                "冷板码前缀不能为空",
+                "输入错误",
+                JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+
+        String bottomPlatePrefix = bottomPlatePrefixField.getText().trim();
+        if (bottomPlatePrefix.isEmpty()) {
+            JOptionPane.showMessageDialog(this,
+                "底护板码前缀不能为空",
+                "输入错误",
+                JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+
+        // 验证格式(逗号分隔,每段不能为空)
+        if (!checkPrefixFormat(productPrefix, "工件码") ||
+            !checkPrefixFormat(coldPlatePrefix, "冷板码") ||
+            !checkPrefixFormat(bottomPlatePrefix, "底护板码")) {
+            return;
+        }
+
+        try {
+            ProjectConfigManager pm = ProjectConfigManager.getInstance();
+            pm.setProductPrefixOverride(productPrefix);
+            pm.setColdPlatePrefixOverride(coldPlatePrefix);
+            pm.setBottomPlatePrefixOverride(bottomPlatePrefix);
+
+            saved = true;
+            JOptionPane.showMessageDialog(this,
+                "前缀配置已保存,立即生效",
+                "保存成功",
+                JOptionPane.INFORMATION_MESSAGE);
+
+            log.info("前缀配置已更新: 工件={}, 冷板={}, 底护板={}",
+                     productPrefix, coldPlatePrefix, bottomPlatePrefix);
+            dispose();
+
+        } catch (Exception ex) {
+            log.error("保存前缀配置失败", ex);
+            JOptionPane.showMessageDialog(this,
+                "保存配置失败: " + ex.getMessage(),
+                "错误",
+                JOptionPane.ERROR_MESSAGE);
+        }
+    }
+
+    private void resetToDefault() {
+        int result = JOptionPane.showConfirmDialog(this,
+            "确认清除前缀覆盖配置,恢复使用当前产品的默认前缀?",
+            "确认恢复默认",
+            JOptionPane.YES_NO_OPTION,
+            JOptionPane.QUESTION_MESSAGE);
+
+        if (result != JOptionPane.YES_OPTION) return;
+
+        try {
+            ProjectConfigManager pm = ProjectConfigManager.getInstance();
+            pm.clearPrefixOverrides();
+
+            saved = true;
+            JOptionPane.showMessageDialog(this,
+                "已恢复默认前缀配置,立即生效",
+                "恢复成功",
+                JOptionPane.INFORMATION_MESSAGE);
+
+            log.info("前缀配置已恢复默认");
+            dispose();
+
+        } catch (Exception ex) {
+            log.error("恢复默认前缀配置失败", ex);
+            JOptionPane.showMessageDialog(this,
+                "恢复默认配置失败: " + ex.getMessage(),
+                "错误",
+                JOptionPane.ERROR_MESSAGE);
+        }
+    }
+
+    // 校验逗号分隔的前缀格式,每段不能为空
+    private boolean checkPrefixFormat(String value, String name) {
+        String[] parts = value.split(",");
+        if (parts.length == 0) {
+            JOptionPane.showMessageDialog(this,
+                name + "前缀不能为空",
+                "输入错误",
+                JOptionPane.ERROR_MESSAGE);
+            return false;
+        }
+        for (String p : parts) {
+            if (p.trim().isEmpty()) {
+                JOptionPane.showMessageDialog(this,
+                    name + "前缀格式错误,请用逗号分隔有效前缀,每段不能为空",
+                    "输入错误",
+                    JOptionPane.ERROR_MESSAGE);
+                return false;
+            }
+        }
+        return true;
+    }
+
+    public boolean isSaved() {
+        return saved;
+    }
+}

+ 1 - 1
src/resources/config/station.yaml

@@ -9,7 +9,7 @@ station:
   line_sn: XT
 
 server:
-  ip: 192.168.113.99
+  ip: 192.168.112.99
   tcp_port: 3000
   http_port: 8980
   heart_beat_cycle: 60

+ 59 - 0
test/TestPrefixWhitelist.java

@@ -0,0 +1,59 @@
+import com.mes.core.ProjectConfigManager;
+
+/**
+ * 测试前缀白名单功能
+ */
+public class TestPrefixWhitelist {
+
+    public static void main(String[] args) {
+        ProjectConfigManager pm = ProjectConfigManager.getInstance();
+
+        System.out.println("=== 当前产品:" + pm.getCurrentProductId() + " ===");
+
+        // 测试工件码前缀
+        System.out.println("\n【工件码前缀白名单】");
+        String[] productPrefixes = pm.getProductPrefixes();
+        if (productPrefixes != null) {
+            for (String prefix : productPrefixes) {
+                System.out.println("  - " + prefix);
+            }
+            System.out.println("提示文本: " + pm.getProductPrefixHint());
+        }
+
+        // 测试冷板码前缀
+        System.out.println("\n【冷板码前缀白名单】");
+        String[] coldPlatePrefixes = pm.getColdPlatePrefixes();
+        if (coldPlatePrefixes != null) {
+            for (String prefix : coldPlatePrefixes) {
+                System.out.println("  - " + prefix);
+            }
+            System.out.println("提示文本: " + pm.getColdPlatePrefixHint());
+        }
+
+        // 测试底护板码前缀
+        System.out.println("\n【底护板码前缀白名单】");
+        String[] bottomPlatePrefixes = pm.getBottomPlatePrefixes();
+        if (bottomPlatePrefixes != null) {
+            for (String prefix : bottomPlatePrefixes) {
+                System.out.println("  - " + prefix);
+            }
+            System.out.println("提示文本: " + pm.getBottomPlatePrefixHint());
+        }
+
+        // 测试匹配逻辑
+        System.out.println("\n【匹配测试】");
+        String[] testCodes = {"+KA99123", "+KC14456", "+KB98789", "+INVALID"};
+        for (String code : testCodes) {
+            boolean match = pm.matchProductPrefix(code);
+            System.out.println("  " + code + " -> " + (match ? "✓ 匹配" : "✗ 不匹配"));
+        }
+
+        // 测试冷板码匹配
+        System.out.println("\n【冷板码匹配测试】");
+        String[] testColdPlates = {"+KB004123", "2510456", "+INVALID"};
+        for (String code : testColdPlates) {
+            boolean match = pm.matchColdPlatePrefix(code);
+            System.out.println("  " + code + " -> " + (match ? "✓ 匹配" : "✗ 不匹配"));
+        }
+    }
+}