#
Junjie
3 天以前 78443e96a6b02853b3f7869ededc459a558dacf0
#
19个文件已添加
1个文件已修改
5088 ■■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/BasStationPathPolicyController.java 420 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/path/StationPathProfileConfig.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/path/StationPathResolvedPolicy.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/path/StationPathRuleConfig.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/entity/BasStationPathProfile.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/entity/BasStationPathRule.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/mapper/BasStationPathProfileMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/mapper/BasStationPathRuleMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/BasStationPathProfileService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/BasStationPathRuleService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/StationPathPolicyService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/BasStationPathProfileServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/BasStationPathRuleServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/StationPathPolicyServiceImpl.java 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/utils/NavigateUtils.java 450 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260313_add_station_path_policy_menu.sql 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260313_create_station_path_policy_tables.sql 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/stationPathPolicy/stationPathPolicy.js 1303 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/stationPathPolicy/stationPathPolicy.html 1300 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tmp/docs/wcs_wms_plan_check.html 922 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/BasStationPathPolicyController.java
New file
@@ -0,0 +1,420 @@
package com.zy.asrs.controller;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.annotations.ManagerAuth;
import com.core.common.R;
import com.zy.asrs.domain.path.StationPathResolvedPolicy;
import com.zy.asrs.entity.BasMap;
import com.zy.asrs.entity.BasStation;
import com.zy.asrs.entity.BasStationPathProfile;
import com.zy.asrs.entity.BasStationPathRule;
import com.zy.asrs.service.BasMapService;
import com.zy.asrs.service.BasStationService;
import com.zy.asrs.service.BasStationPathProfileService;
import com.zy.asrs.service.BasStationPathRuleService;
import com.zy.asrs.service.StationPathPolicyService;
import com.zy.common.model.NavigateNode;
import com.zy.common.utils.NavigateUtils;
import com.zy.common.utils.RedisUtil;
import com.zy.common.web.BaseController;
import com.zy.core.enums.RedisKeyType;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/basStationPathPolicy")
public class BasStationPathPolicyController extends BaseController {
    @Autowired
    private BasStationPathProfileService basStationPathProfileService;
    @Autowired
    private BasStationPathRuleService basStationPathRuleService;
    @Autowired
    private ConfigService configService;
    @Autowired
    private RedisUtil redisUtil;
    @Autowired
    private StationPathPolicyService stationPathPolicyService;
    @Autowired
    private BasStationService basStationService;
    @Autowired
    private BasMapService basMapService;
    @Autowired
    private NavigateUtils navigateUtils;
    @RequestMapping("/data/auth")
    @ManagerAuth
    public R data() {
        Map<String, Object> data = new HashMap<>();
        data.put("profiles", basStationPathProfileService.list(new QueryWrapper<BasStationPathProfile>().orderByAsc("priority", "id")));
        data.put("rules", basStationPathRuleService.list(new QueryWrapper<BasStationPathRule>().orderByAsc("priority", "id")));
        data.put("scoreMode", getSystemConfig("stationPathScoreMode", "legacy"));
        data.put("defaultProfileCode", getSystemConfig("stationPathDefaultProfileCode", "default"));
        data.put("stations", buildStationSummaryList());
        data.put("levList", basMapService.getLevList());
        return R.ok(data);
    }
    @RequestMapping("/save/auth")
    @ManagerAuth
    public R save(@RequestBody JSONObject payload) {
        JSONArray profiles = payload.getJSONArray("profiles");
        JSONArray rules = payload.getJSONArray("rules");
        upsertSystemConfig("站点路径评分模式", "stationPathScoreMode", defaultIfBlank(payload.getString("scoreMode"), "legacy"), "String");
        upsertSystemConfig("站点路径默认模板编码", "stationPathDefaultProfileCode", defaultIfBlank(payload.getString("defaultProfileCode"), "default"), "String");
        basStationPathProfileService.remove(new QueryWrapper<>());
        basStationPathRuleService.remove(new QueryWrapper<>());
        List<BasStationPathProfile> profileList = new ArrayList<>();
        if (profiles != null) {
            for (int i = 0; i < profiles.size(); i++) {
                JSONObject item = profiles.getJSONObject(i);
                if (item == null) {
                    continue;
                }
                if (isBlank(item.getString("profileCode"))) {
                    continue;
                }
                BasStationPathProfile profile = new BasStationPathProfile();
                profile.setProfileCode(item.getString("profileCode"));
                profile.setProfileName(defaultIfBlank(item.getString("profileName"), item.getString("profileCode")));
                profile.setPriority(item.getInteger("priority") == null ? 100 : item.getInteger("priority"));
                profile.setStatus(item.getShort("status") == null ? (short) 1 : item.getShort("status"));
                profile.setMemo(item.getString("memo"));
                Object configObj = item.get("config");
                if (configObj != null) {
                    profile.setConfigJson(JSON.toJSONString(configObj));
                } else {
                    profile.setConfigJson(item.getString("configJson"));
                }
                profileList.add(profile);
            }
        }
        if (!profileList.isEmpty()) {
            basStationPathProfileService.saveBatch(profileList);
        }
        List<BasStationPathRule> ruleList = new ArrayList<>();
        if (rules != null) {
            for (int i = 0; i < rules.size(); i++) {
                JSONObject item = rules.getJSONObject(i);
                if (item == null) {
                    continue;
                }
                if (isBlank(item.getString("ruleCode"))) {
                    continue;
                }
                BasStationPathRule rule = new BasStationPathRule();
                rule.setRuleCode(item.getString("ruleCode"));
                rule.setRuleName(defaultIfBlank(item.getString("ruleName"), item.getString("ruleCode")));
                rule.setPriority(item.getInteger("priority") == null ? 100 : item.getInteger("priority"));
                rule.setStatus(item.getShort("status") == null ? (short) 1 : item.getShort("status"));
                rule.setSceneType(item.getString("sceneType"));
                rule.setStartStationId(item.getInteger("startStationId"));
                rule.setEndStationId(item.getInteger("endStationId"));
                rule.setProfileCode(item.getString("profileCode"));
                rule.setMemo(item.getString("memo"));
                rule.setHardJson(toJson(item.get("hard"), item.getString("hardJson")));
                rule.setWaypointJson(toJson(item.get("waypoint"), item.getString("waypointJson")));
                rule.setSoftJson(toJson(item.get("soft"), item.getString("softJson")));
                rule.setFallbackJson(toJson(item.get("fallback"), item.getString("fallbackJson")));
                ruleList.add(rule);
            }
        }
        if (!ruleList.isEmpty()) {
            basStationPathRuleService.saveBatch(ruleList);
        }
        refreshSystemConfigCache();
        stationPathPolicyService.evictCache();
        return R.ok();
    }
    @RequestMapping("/resolve/auth")
    @ManagerAuth
    public R resolve(@RequestParam Integer startStationId, @RequestParam Integer endStationId) {
        StationPathResolvedPolicy resolved = stationPathPolicyService.resolvePolicy(startStationId, endStationId);
        return R.ok(resolved);
    }
    @RequestMapping("/preview/auth")
    @ManagerAuth
    public R preview(@RequestParam Integer startStationId,
                     @RequestParam Integer endStationId,
                     @RequestParam(required = false, defaultValue = "false") Boolean includeMapData) {
        StationPathResolvedPolicy resolved = stationPathPolicyService.resolvePolicy(startStationId, endStationId);
        List<NavigateNode> nodes = navigateUtils.calcByStationId(startStationId, endStationId);
        List<Integer> stationIdList = new ArrayList<>();
        List<Map<String, Object>> nodeList = new ArrayList<>();
        Set<Integer> seen = new HashSet<>();
        for (NavigateNode node : nodes) {
            JSONObject value = parseNodeValue(node == null ? null : node.getNodeValue());
            Integer stationId = value == null ? null : value.getInteger("stationId");
            if (stationId != null && seen.add(stationId)) {
                stationIdList.add(stationId);
            }
            Map<String, Object> item = new HashMap<>();
            item.put("stationId", stationId);
            item.put("x", node == null ? null : node.getX());
            item.put("y", node == null ? null : node.getY());
            item.put("direction", node == null ? null : node.getDirection());
            item.put("isInflectionPoint", node != null && Boolean.TRUE.equals(node.getIsInflectionPoint()));
            item.put("isLiftTransferPoint", node != null && Boolean.TRUE.equals(node.getIsLiftTransferPoint()));
            nodeList.add(item);
        }
        BasStation startStation = basStationService.getById(startStationId);
        Integer lev = startStation == null ? null : startStation.getStationLev();
        BasMap basMap = Boolean.TRUE.equals(includeMapData) && lev != null
                ? basMapService.getOne(new QueryWrapper<BasMap>().eq("lev", lev))
                : null;
        Map<String, Object> result = new HashMap<>();
        result.put("resolvedPolicy", resolved);
        result.put("pathStationIds", stationIdList);
        result.put("pathNodes", nodeList);
        result.put("pathLength", stationIdList.size());
        result.put("turnCount", countTurnCount(nodeList));
        result.put("liftTransferCount", countLiftTransferCount(nodeList));
        result.put("lev", lev);
        result.put("mapData", basMap == null ? null : basMap.getData());
        result.put("previewTime", new Date());
        return R.ok(result);
    }
    @RequestMapping("/expandSoftPath/auth")
    @ManagerAuth
    public R expandSoftPath(@RequestBody JSONObject payload) {
        Integer startStationId = payload.getInteger("startStationId");
        Integer endStationId = payload.getInteger("endStationId");
        if (startStationId == null || endStationId == null) {
            return R.error("起点和终点不能为空");
        }
        List<Integer> routePoints = new ArrayList<>();
        routePoints.add(startStationId);
        routePoints.addAll(parseStationIdArray(payload.getJSONArray("keyStations")));
        routePoints.add(endStationId);
        List<Integer> fullPathStationIds = new ArrayList<>();
        for (int i = 0; i < routePoints.size() - 1; i++) {
            Integer segmentStart = routePoints.get(i);
            Integer segmentEnd = routePoints.get(i + 1);
            if (segmentStart == null || segmentEnd == null) {
                continue;
            }
            if (segmentStart.equals(segmentEnd)) {
                if (fullPathStationIds.isEmpty()) {
                    fullPathStationIds.add(segmentStart);
                }
                continue;
            }
            List<NavigateNode> segmentNodes = navigateUtils.calcByStationId(segmentStart, segmentEnd);
            List<Integer> segmentStationIds = extractStationIds(segmentNodes);
            if (segmentStationIds.isEmpty()) {
                return R.error("未找到 " + segmentStart + " 到 " + segmentEnd + " 的可行路径");
            }
            appendSegmentPath(fullPathStationIds, segmentStationIds);
        }
        Map<String, Object> result = new HashMap<>();
        result.put("startStationId", startStationId);
        result.put("endStationId", endStationId);
        result.put("keyStations", routePoints.size() <= 2 ? new ArrayList<>() : routePoints.subList(1, routePoints.size() - 1));
        result.put("pathStationIds", fullPathStationIds);
        result.put("pathLength", fullPathStationIds.size());
        result.put("segmentCount", Math.max(routePoints.size() - 1, 0));
        result.put("previewTime", new Date());
        return R.ok(result);
    }
    private List<Map<String, Object>> buildStationSummaryList() {
        List<Map<String, Object>> result = new ArrayList<>();
        List<BasStation> stationList = basStationService.list(new QueryWrapper<BasStation>()
                .eq("status", 1)
                .orderByAsc("station_lev", "station_id"));
        for (BasStation station : stationList) {
            if (station == null || station.getStationId() == null) {
                continue;
            }
            Map<String, Object> item = new HashMap<>();
            item.put("stationId", station.getStationId());
            item.put("stationLev", station.getStationLev());
            item.put("stationAlias", station.getStationAlias());
            result.add(item);
        }
        return result;
    }
    private void upsertSystemConfig(String name, String code, String value, String selectType) {
        String finalValue = value == null ? "" : value.trim();
        Config config = configService.getOne(new QueryWrapper<Config>().eq("code", code));
        if (config == null) {
            config = new Config(name, code, finalValue, (short) 1, (short) 1);
            config.setSelectType(selectType);
            configService.save(config);
        } else {
            config.setName(name);
            config.setValue(finalValue);
            config.setType((short) 1);
            config.setStatus((short) 1);
            config.setSelectType(selectType);
            configService.updateById(config);
        }
    }
    private void refreshSystemConfigCache() {
        HashMap<String, String> systemConfigMap = new HashMap<>();
        List<Config> configList = configService.list(new QueryWrapper<>());
        for (Config config : configList) {
            systemConfigMap.put(config.getCode(), config.getValue());
        }
        redisUtil.set(RedisKeyType.SYSTEM_CONFIG_MAP.key, systemConfigMap);
    }
    private String getSystemConfig(String code, String defaultValue) {
        Object mapObj = redisUtil.get(RedisKeyType.SYSTEM_CONFIG_MAP.key);
        if (mapObj instanceof Map) {
            Object value = ((Map<?, ?>) mapObj).get(code);
            if (value != null) {
                String text = String.valueOf(value).trim();
                if (!text.isEmpty()) {
                    return text;
                }
            }
        }
        return defaultValue;
    }
    private List<Integer> parseStationIdArray(JSONArray array) {
        List<Integer> result = new ArrayList<>();
        if (array == null || array.isEmpty()) {
            return result;
        }
        Set<Integer> seen = new HashSet<>();
        for (int i = 0; i < array.size(); i++) {
            Integer stationId = toInteger(array.get(i));
            if (stationId != null && seen.add(stationId)) {
                result.add(stationId);
            }
        }
        return result;
    }
    private List<Integer> extractStationIds(List<NavigateNode> nodes) {
        List<Integer> stationIdList = new ArrayList<>();
        Set<Integer> seen = new HashSet<>();
        for (NavigateNode node : nodes) {
            JSONObject value = parseNodeValue(node == null ? null : node.getNodeValue());
            Integer stationId = value == null ? null : value.getInteger("stationId");
            if (stationId != null && seen.add(stationId)) {
                stationIdList.add(stationId);
            }
        }
        return stationIdList;
    }
    private void appendSegmentPath(List<Integer> fullPathStationIds, List<Integer> segmentStationIds) {
        if (segmentStationIds == null || segmentStationIds.isEmpty()) {
            return;
        }
        for (Integer stationId : segmentStationIds) {
            if (stationId == null) {
                continue;
            }
            if (fullPathStationIds.isEmpty() || !stationId.equals(fullPathStationIds.get(fullPathStationIds.size() - 1))) {
                fullPathStationIds.add(stationId);
            }
        }
    }
    private String toJson(Object obj, String fallbackText) {
        if (obj != null) {
            return JSON.toJSONString(obj);
        }
        return fallbackText;
    }
    private JSONObject parseNodeValue(String text) {
        if (isBlank(text)) {
            return null;
        }
        try {
            return JSON.parseObject(text);
        } catch (Exception ignore) {
            return null;
        }
    }
    private int countTurnCount(List<Map<String, Object>> nodeList) {
        if (nodeList == null || nodeList.size() < 3) {
            return 0;
        }
        int count = 0;
        for (int i = 1; i < nodeList.size() - 1; i++) {
            Map<String, Object> prev = nodeList.get(i - 1);
            Map<String, Object> next = nodeList.get(i + 1);
            Integer prevX = toInteger(prev.get("x"));
            Integer prevY = toInteger(prev.get("y"));
            Integer nextX = toInteger(next.get("x"));
            Integer nextY = toInteger(next.get("y"));
            if (prevX == null || prevY == null || nextX == null || nextY == null) {
                continue;
            }
            if (!prevX.equals(nextX) && !prevY.equals(nextY)) {
                count++;
            }
        }
        return count;
    }
    private int countLiftTransferCount(List<Map<String, Object>> nodeList) {
        int count = 0;
        for (Map<String, Object> item : nodeList) {
            if (Boolean.TRUE.equals(item.get("isLiftTransferPoint"))) {
                count++;
            }
        }
        return count;
    }
    private Integer toInteger(Object value) {
        if (value == null) {
            return null;
        }
        if (value instanceof Integer) {
            return (Integer) value;
        }
        try {
            return Integer.parseInt(String.valueOf(value));
        } catch (Exception ignore) {
            return null;
        }
    }
    private String defaultIfBlank(String text, String defaultValue) {
        return isBlank(text) ? defaultValue : text.trim();
    }
    private boolean isBlank(String text) {
        return text == null || text.trim().isEmpty();
    }
}
src/main/java/com/zy/asrs/domain/path/StationPathProfileConfig.java
New file
@@ -0,0 +1,49 @@
package com.zy.asrs.domain.path;
import lombok.Data;
import java.io.Serializable;
@Data
public class StationPathProfileConfig implements Serializable {
    private static final long serialVersionUID = 1L;
    private Integer calcMaxDepth = 120;
    private Integer calcMaxPaths = 500;
    private Integer calcMaxCost = 300;
    private Integer s1TopK = 5;
    private Double s1LenWeight = 1.0d;
    private Double s1TurnWeight = 3.0d;
    private Double s1LiftWeight = 8.0d;
    private Double s1SoftDeviationWeight = 4.0d;
    private Double s1MaxLenRatio = 1.15d;
    private Integer s1MaxTurnDiff = 1;
    private Double s2BusyWeight = 2.0d;
    private Double s2RunBlockWeight = 10.0d;
    private Double s2LoopLoadWeight = 12.0d;
    public static StationPathProfileConfig defaultConfig() {
        return new StationPathProfileConfig();
    }
    public void mergeFrom(StationPathProfileConfig source) {
        if (source == null) {
            return;
        }
        if (source.calcMaxDepth != null) this.calcMaxDepth = source.calcMaxDepth;
        if (source.calcMaxPaths != null) this.calcMaxPaths = source.calcMaxPaths;
        if (source.calcMaxCost != null) this.calcMaxCost = source.calcMaxCost;
        if (source.s1TopK != null) this.s1TopK = source.s1TopK;
        if (source.s1LenWeight != null) this.s1LenWeight = source.s1LenWeight;
        if (source.s1TurnWeight != null) this.s1TurnWeight = source.s1TurnWeight;
        if (source.s1LiftWeight != null) this.s1LiftWeight = source.s1LiftWeight;
        if (source.s1SoftDeviationWeight != null) this.s1SoftDeviationWeight = source.s1SoftDeviationWeight;
        if (source.s1MaxLenRatio != null) this.s1MaxLenRatio = source.s1MaxLenRatio;
        if (source.s1MaxTurnDiff != null) this.s1MaxTurnDiff = source.s1MaxTurnDiff;
        if (source.s2BusyWeight != null) this.s2BusyWeight = source.s2BusyWeight;
        if (source.s2RunBlockWeight != null) this.s2RunBlockWeight = source.s2RunBlockWeight;
        if (source.s2LoopLoadWeight != null) this.s2LoopLoadWeight = source.s2LoopLoadWeight;
    }
}
src/main/java/com/zy/asrs/domain/path/StationPathResolvedPolicy.java
New file
@@ -0,0 +1,27 @@
package com.zy.asrs.domain.path;
import com.zy.asrs.entity.BasStationPathProfile;
import com.zy.asrs.entity.BasStationPathRule;
import lombok.Data;
import java.io.Serializable;
@Data
public class StationPathResolvedPolicy implements Serializable {
    private static final long serialVersionUID = 1L;
    private String scoreMode = "legacy";
    private String defaultProfileCode = "default";
    private BasStationPathProfile profileEntity;
    private BasStationPathRule ruleEntity;
    private StationPathProfileConfig profileConfig = StationPathProfileConfig.defaultConfig();
    private StationPathRuleConfig ruleConfig = new StationPathRuleConfig();
    public boolean useTwoStage() {
        return "twoStage".equalsIgnoreCase(scoreMode);
    }
    public boolean matchedRule() {
        return ruleEntity != null;
    }
}
src/main/java/com/zy/asrs/domain/path/StationPathRuleConfig.java
New file
@@ -0,0 +1,48 @@
package com.zy.asrs.domain.path;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class StationPathRuleConfig implements Serializable {
    private static final long serialVersionUID = 1L;
    private HardConstraint hard = new HardConstraint();
    private WaypointConstraint waypoint = new WaypointConstraint();
    private SoftPreference soft = new SoftPreference();
    private FallbackPolicy fallback = new FallbackPolicy();
    @Data
    public static class HardConstraint implements Serializable {
        private static final long serialVersionUID = 1L;
        private List<Integer> mustPassStations = new ArrayList<>();
        private List<Integer> forbidStations = new ArrayList<>();
        private List<String> mustPassEdges = new ArrayList<>();
        private List<String> forbidEdges = new ArrayList<>();
    }
    @Data
    public static class WaypointConstraint implements Serializable {
        private static final long serialVersionUID = 1L;
        private List<Integer> stations = new ArrayList<>();
    }
    @Data
    public static class SoftPreference implements Serializable {
        private static final long serialVersionUID = 1L;
        private List<Integer> keyStations = new ArrayList<>();
        private List<Integer> preferredPath = new ArrayList<>();
        private Double deviationWeight = 6.0d;
        private Integer maxOffPathCount = 2;
    }
    @Data
    public static class FallbackPolicy implements Serializable {
        private static final long serialVersionUID = 1L;
        private Boolean strictWaypoint = false;
        private Boolean allowSoftDegrade = true;
    }
}
src/main/java/com/zy/asrs/entity/BasStationPathProfile.java
New file
@@ -0,0 +1,40 @@
package com.zy.asrs.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("asr_bas_station_path_profile")
public class BasStationPathProfile implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @TableField("profile_code")
    private String profileCode;
    @TableField("profile_name")
    private String profileName;
    private Integer priority;
    private Short status;
    @TableField("config_json")
    private String configJson;
    private String memo;
    @TableField("create_time")
    private Date createTime;
    @TableField("update_time")
    private Date updateTime;
}
src/main/java/com/zy/asrs/entity/BasStationPathRule.java
New file
@@ -0,0 +1,61 @@
package com.zy.asrs.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("asr_bas_station_path_rule")
public class BasStationPathRule implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @TableField("rule_code")
    private String ruleCode;
    @TableField("rule_name")
    private String ruleName;
    private Integer priority;
    private Short status;
    @TableField("scene_type")
    private String sceneType;
    @TableField("start_station_id")
    private Integer startStationId;
    @TableField("end_station_id")
    private Integer endStationId;
    @TableField("profile_code")
    private String profileCode;
    @TableField("hard_json")
    private String hardJson;
    @TableField("waypoint_json")
    private String waypointJson;
    @TableField("soft_json")
    private String softJson;
    @TableField("fallback_json")
    private String fallbackJson;
    private String memo;
    @TableField("create_time")
    private Date createTime;
    @TableField("update_time")
    private Date updateTime;
}
src/main/java/com/zy/asrs/mapper/BasStationPathProfileMapper.java
New file
@@ -0,0 +1,11 @@
package com.zy.asrs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zy.asrs.entity.BasStationPathProfile;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface BasStationPathProfileMapper extends BaseMapper<BasStationPathProfile> {
}
src/main/java/com/zy/asrs/mapper/BasStationPathRuleMapper.java
New file
@@ -0,0 +1,11 @@
package com.zy.asrs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zy.asrs.entity.BasStationPathRule;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface BasStationPathRuleMapper extends BaseMapper<BasStationPathRule> {
}
src/main/java/com/zy/asrs/service/BasStationPathProfileService.java
New file
@@ -0,0 +1,7 @@
package com.zy.asrs.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zy.asrs.entity.BasStationPathProfile;
public interface BasStationPathProfileService extends IService<BasStationPathProfile> {
}
src/main/java/com/zy/asrs/service/BasStationPathRuleService.java
New file
@@ -0,0 +1,7 @@
package com.zy.asrs.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zy.asrs.entity.BasStationPathRule;
public interface BasStationPathRuleService extends IService<BasStationPathRule> {
}
src/main/java/com/zy/asrs/service/StationPathPolicyService.java
New file
@@ -0,0 +1,10 @@
package com.zy.asrs.service;
import com.zy.asrs.domain.path.StationPathResolvedPolicy;
public interface StationPathPolicyService {
    StationPathResolvedPolicy resolvePolicy(Integer startStationId, Integer endStationId);
    void evictCache();
}
src/main/java/com/zy/asrs/service/impl/BasStationPathProfileServiceImpl.java
New file
@@ -0,0 +1,11 @@
package com.zy.asrs.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zy.asrs.entity.BasStationPathProfile;
import com.zy.asrs.mapper.BasStationPathProfileMapper;
import com.zy.asrs.service.BasStationPathProfileService;
import org.springframework.stereotype.Service;
@Service
public class BasStationPathProfileServiceImpl extends ServiceImpl<BasStationPathProfileMapper, BasStationPathProfile> implements BasStationPathProfileService {
}
src/main/java/com/zy/asrs/service/impl/BasStationPathRuleServiceImpl.java
New file
@@ -0,0 +1,11 @@
package com.zy.asrs.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zy.asrs.entity.BasStationPathRule;
import com.zy.asrs.mapper.BasStationPathRuleMapper;
import com.zy.asrs.service.BasStationPathRuleService;
import org.springframework.stereotype.Service;
@Service
public class BasStationPathRuleServiceImpl extends ServiceImpl<BasStationPathRuleMapper, BasStationPathRule> implements BasStationPathRuleService {
}
src/main/java/com/zy/asrs/service/impl/StationPathPolicyServiceImpl.java
New file
@@ -0,0 +1,264 @@
package com.zy.asrs.service.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.zy.asrs.domain.path.StationPathProfileConfig;
import com.zy.asrs.domain.path.StationPathResolvedPolicy;
import com.zy.asrs.domain.path.StationPathRuleConfig;
import com.zy.asrs.entity.BasStationPathProfile;
import com.zy.asrs.entity.BasStationPathRule;
import com.zy.asrs.service.BasStationPathProfileService;
import com.zy.asrs.service.BasStationPathRuleService;
import com.zy.asrs.service.StationPathPolicyService;
import com.zy.common.utils.RedisUtil;
import com.zy.core.enums.RedisKeyType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service("stationPathPolicyService")
@Slf4j
public class StationPathPolicyServiceImpl implements StationPathPolicyService {
    private static final long CACHE_TTL_MILLIS = 5_000L;
    @Autowired
    private BasStationPathProfileService basStationPathProfileService;
    @Autowired
    private BasStationPathRuleService basStationPathRuleService;
    @Autowired
    private RedisUtil redisUtil;
    private volatile CacheSnapshot cacheSnapshot = new CacheSnapshot();
    private volatile long cacheTime = 0L;
    @Override
    public StationPathResolvedPolicy resolvePolicy(Integer startStationId, Integer endStationId) {
        StationPathResolvedPolicy resolved = new StationPathResolvedPolicy();
        resolved.setScoreMode(getSystemConfig("stationPathScoreMode", "legacy"));
        resolved.setDefaultProfileCode(getSystemConfig("stationPathDefaultProfileCode", "default"));
        CacheSnapshot snapshot = getCacheSnapshot();
        BasStationPathRule matchedRule = matchRule(snapshot.ruleList, startStationId, endStationId);
        BasStationPathProfile matchedProfile = null;
        if (matchedRule != null && notBlank(matchedRule.getProfileCode())) {
            matchedProfile = snapshot.profileMap.get(matchedRule.getProfileCode());
        }
        if (matchedProfile == null && notBlank(resolved.getDefaultProfileCode())) {
            matchedProfile = snapshot.profileMap.get(resolved.getDefaultProfileCode());
        }
        if (matchedProfile == null) {
            matchedProfile = snapshot.profileMap.get("default");
        }
        if (matchedProfile == null && !snapshot.profileList.isEmpty()) {
            matchedProfile = snapshot.profileList.get(0);
        }
        resolved.setRuleEntity(matchedRule);
        resolved.setProfileEntity(matchedProfile);
        resolved.setProfileConfig(parseProfileConfig(matchedProfile == null ? null : matchedProfile.getConfigJson()));
        resolved.setRuleConfig(parseRuleConfig(matchedRule));
        return resolved;
    }
    @Override
    public void evictCache() {
        cacheSnapshot = new CacheSnapshot();
        cacheTime = 0L;
    }
    private CacheSnapshot getCacheSnapshot() {
        long now = System.currentTimeMillis();
        CacheSnapshot local = cacheSnapshot;
        if (local.loaded && now - cacheTime < CACHE_TTL_MILLIS) {
            return local;
        }
        synchronized (this) {
            local = cacheSnapshot;
            if (local.loaded && now - cacheTime < CACHE_TTL_MILLIS) {
                return local;
            }
            cacheSnapshot = loadSnapshot();
            cacheTime = System.currentTimeMillis();
            return cacheSnapshot;
        }
    }
    private CacheSnapshot loadSnapshot() {
        CacheSnapshot snapshot = new CacheSnapshot();
        try {
            List<BasStationPathProfile> profiles = basStationPathProfileService.list(new QueryWrapper<BasStationPathProfile>()
                    .eq("status", 1)
                    .orderByAsc("priority", "id"));
            if (profiles != null) {
                snapshot.profileList.addAll(profiles);
                for (BasStationPathProfile profile : profiles) {
                    if (profile != null && notBlank(profile.getProfileCode())) {
                        snapshot.profileMap.put(profile.getProfileCode(), profile);
                    }
                }
            }
        } catch (Exception e) {
            log.warn("加载站点路径模板失败,回退默认配置: {}", e.getMessage());
        }
        try {
            List<BasStationPathRule> rules = basStationPathRuleService.list(new QueryWrapper<BasStationPathRule>()
                    .eq("status", 1)
                    .orderByAsc("priority", "id"));
            if (rules != null) {
                snapshot.ruleList.addAll(rules);
            }
        } catch (Exception e) {
            log.warn("加载站点路径规则失败,忽略人工规则: {}", e.getMessage());
        }
        snapshot.loaded = true;
        return snapshot;
    }
    private BasStationPathRule matchRule(List<BasStationPathRule> ruleList, Integer startStationId, Integer endStationId) {
        if (ruleList == null || ruleList.isEmpty()) {
            return null;
        }
        List<BasStationPathRule> candidates = new ArrayList<>();
        for (BasStationPathRule rule : ruleList) {
            if (rule == null) {
                continue;
            }
            if (!matchNullable(rule.getStartStationId(), startStationId)) {
                continue;
            }
            if (!matchNullable(rule.getEndStationId(), endStationId)) {
                continue;
            }
            candidates.add(rule);
        }
        if (candidates.isEmpty()) {
            return null;
        }
        candidates.sort(Comparator
                .comparingInt(this::specificity).reversed()
                .thenComparingInt(rule -> safeInt(rule.getPriority(), 100))
                .thenComparingLong(rule -> rule.getId() == null ? Long.MAX_VALUE : rule.getId()));
        return candidates.get(0);
    }
    private boolean matchNullable(Integer expected, Integer actual) {
        return expected == null || expected.equals(actual);
    }
    private int specificity(BasStationPathRule rule) {
        int score = 0;
        if (rule.getStartStationId() != null) {
            score += 1;
        }
        if (rule.getEndStationId() != null) {
            score += 1;
        }
        return score;
    }
    private StationPathProfileConfig parseProfileConfig(String configJson) {
        StationPathProfileConfig config = StationPathProfileConfig.defaultConfig();
        if (!notBlank(configJson)) {
            return config;
        }
        try {
            StationPathProfileConfig parsed = JSON.parseObject(configJson, StationPathProfileConfig.class);
            config.mergeFrom(parsed);
        } catch (Exception e) {
            log.warn("解析站点路径模板配置失败,使用默认值: {}", e.getMessage());
        }
        return config;
    }
    private StationPathRuleConfig parseRuleConfig(BasStationPathRule rule) {
        StationPathRuleConfig config = new StationPathRuleConfig();
        if (rule == null) {
            return config;
        }
        try {
            if (notBlank(rule.getHardJson())) {
                StationPathRuleConfig.HardConstraint hard = JSON.parseObject(rule.getHardJson(), StationPathRuleConfig.HardConstraint.class);
                if (hard != null) {
                    config.setHard(hard);
                }
            }
        } catch (Exception e) {
            log.warn("解析硬约束配置失败, ruleCode={}", rule.getRuleCode());
        }
        try {
            if (notBlank(rule.getWaypointJson())) {
                StationPathRuleConfig.WaypointConstraint waypoint = JSON.parseObject(rule.getWaypointJson(), StationPathRuleConfig.WaypointConstraint.class);
                if (waypoint != null) {
                    config.setWaypoint(waypoint);
                }
            }
        } catch (Exception e) {
            log.warn("解析途经点配置失败, ruleCode={}", rule.getRuleCode());
        }
        try {
            if (notBlank(rule.getSoftJson())) {
                StationPathRuleConfig.SoftPreference soft = JSON.parseObject(rule.getSoftJson(), StationPathRuleConfig.SoftPreference.class);
                if (soft != null) {
                    config.setSoft(soft);
                }
            }
        } catch (Exception e) {
            log.warn("解析软偏好配置失败, ruleCode={}", rule.getRuleCode());
        }
        try {
            if (notBlank(rule.getFallbackJson())) {
                StationPathRuleConfig.FallbackPolicy fallback = JSON.parseObject(rule.getFallbackJson(), StationPathRuleConfig.FallbackPolicy.class);
                if (fallback != null) {
                    config.setFallback(fallback);
                }
            }
        } catch (Exception e) {
            log.warn("解析规则降级配置失败, ruleCode={}", rule.getRuleCode());
        }
        return config;
    }
    private String getSystemConfig(String code, String defaultValue) {
        try {
            Object mapObj = redisUtil.get(RedisKeyType.SYSTEM_CONFIG_MAP.key);
            if (mapObj instanceof Map) {
                Object value = ((Map<?, ?>) mapObj).get(code);
                if (value != null) {
                    String text = String.valueOf(value).trim();
                    if (!text.isEmpty()) {
                        return text;
                    }
                }
            }
        } catch (Exception ignore) {
        }
        return defaultValue;
    }
    private boolean notBlank(String value) {
        return value != null && !value.trim().isEmpty();
    }
    private int safeInt(Integer value, int defaultValue) {
        return value == null ? defaultValue : value;
    }
    private static class CacheSnapshot {
        private boolean loaded = false;
        private final List<BasStationPathProfile> profileList = new ArrayList<>();
        private final List<BasStationPathRule> ruleList = new ArrayList<>();
        private final Map<String, BasStationPathProfile> profileMap = new HashMap<>();
    }
}
src/main/java/com/zy/common/utils/NavigateUtils.java
@@ -8,10 +8,17 @@
import java.util.Map;
import java.util.Set;
import com.zy.asrs.domain.path.StationPathProfileConfig;
import com.zy.asrs.domain.path.StationPathResolvedPolicy;
import com.zy.asrs.domain.path.StationPathRuleConfig;
import com.zy.asrs.domain.vo.StationCycleCapacityVo;
import com.zy.asrs.domain.vo.StationCycleLoopVo;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.entity.BasStation;
import com.zy.asrs.service.BasDevpService;
import com.zy.asrs.service.BasStationService;
import com.zy.asrs.service.StationCycleCapacityService;
import com.zy.asrs.service.StationPathPolicyService;
import com.zy.core.News;
import com.zy.core.model.StationObjModel;
import org.springframework.beans.factory.annotation.Autowired;
@@ -42,6 +49,10 @@
    @Autowired
    private BasStationService basStationService;
    @Autowired
    private StationPathPolicyService stationPathPolicyService;
    @Autowired
    private StationCycleCapacityService stationCycleCapacityService;
    public synchronized List<NavigateNode> calcByStationId(Integer startStationId, Integer endStationId) {
        BasStation startStation = basStationService.getById(startStationId);
@@ -63,9 +74,17 @@
            throw new CoolException("未找到该 终点 对应的节点");
        }
        StationPathResolvedPolicy resolvedPolicy = resolveStationPathPolicy(startStationId, endStationId);
        StationPathProfileConfig profileConfig = resolvedPolicy.getProfileConfig() == null
                ? StationPathProfileConfig.defaultConfig()
                : resolvedPolicy.getProfileConfig();
        long startTime = System.currentTimeMillis();
        News.info("[WCS Debug] 站点路径开始计算,startStationId={},endStationId={}", startStationId, endStationId);
        List<List<NavigateNode>> allList = navigateSolution.allSimplePaths(stationMap, startNode, endNode, 120, 500, 300);
        int calcMaxDepth = resolvedPolicy.useTwoStage() ? safeInt(profileConfig.getCalcMaxDepth(), 120) : 120;
        int calcMaxPaths = resolvedPolicy.useTwoStage() ? safeInt(profileConfig.getCalcMaxPaths(), 500) : 500;
        int calcMaxCost = resolvedPolicy.useTwoStage() ? safeInt(profileConfig.getCalcMaxCost(), 300) : 300;
        List<List<NavigateNode>> allList = navigateSolution.allSimplePaths(stationMap, startNode, endNode, calcMaxDepth, calcMaxPaths, calcMaxCost);
        if (allList.isEmpty()) {
//            throw new CoolException("未找到该路径");
            return new ArrayList<>();
@@ -74,7 +93,9 @@
        startTime = System.currentTimeMillis();
        News.info("[WCS Debug] 站点路径权重开始分析,startStationId={},endStationId={}", startStationId, endStationId);
        List<NavigateNode> list = findStationBestPath(allList);
        List<NavigateNode> list = resolvedPolicy.useTwoStage()
                ? findStationBestPathTwoStage(allList, resolvedPolicy)
                : findStationBestPath(allList);
        News.info("[WCS Debug] 站点路径权重分析完成,耗时:{}ms", System.currentTimeMillis() - startTime);
        //去重
@@ -218,6 +239,431 @@
        return liftStationList;
    }
    private StationPathResolvedPolicy resolveStationPathPolicy(Integer startStationId, Integer endStationId) {
        try {
            if (stationPathPolicyService != null) {
                StationPathResolvedPolicy resolved = stationPathPolicyService.resolvePolicy(startStationId, endStationId);
                if (resolved != null) {
                    return resolved;
                }
            }
        } catch (Exception e) {
            News.warn("站点路径策略加载失败,回退 legacy: {}", e.getMessage());
        }
        return new StationPathResolvedPolicy();
    }
    private List<NavigateNode> findStationBestPathTwoStage(List<List<NavigateNode>> allList, StationPathResolvedPolicy resolvedPolicy) {
        if (allList == null || allList.isEmpty()) {
            return new ArrayList<>();
        }
        StationPathRuleConfig ruleConfig = resolvedPolicy.getRuleConfig() == null
                ? new StationPathRuleConfig()
                : resolvedPolicy.getRuleConfig();
        StationPathProfileConfig profileConfig = resolvedPolicy.getProfileConfig() == null
                ? StationPathProfileConfig.defaultConfig()
                : resolvedPolicy.getProfileConfig();
        List<List<NavigateNode>> filteredCandidates = applyRuleFilters(allList, ruleConfig, true);
        if (filteredCandidates.isEmpty() && hasWaypoint(ruleConfig) && !strictWaypoint(ruleConfig)) {
            filteredCandidates = applyRuleFilters(allList, ruleConfig, false);
            News.info("[WCS Debug] 站点路径规则已降级,忽略关键途经点约束后重试");
        }
        if (filteredCandidates.isEmpty()) {
            if (resolvedPolicy.matchedRule()) {
                News.warn("站点路径规则命中但无可行路径,ruleCode={}",
                        resolvedPolicy.getRuleEntity() == null ? "" : resolvedPolicy.getRuleEntity().getRuleCode());
                return new ArrayList<>();
            }
            filteredCandidates = allList;
        }
        Map<Integer, StationProtocol> statusMap = loadStationStatusMap();
        Map<Integer, Double> stationLoopLoadMap = loadStationLoopLoadMap();
        List<PathCandidateMetrics> metricsList = new ArrayList<>();
        for (List<NavigateNode> path : filteredCandidates) {
            if (path == null || path.isEmpty()) {
                continue;
            }
            metricsList.add(buildCandidateMetrics(path, statusMap, stationLoopLoadMap, profileConfig, ruleConfig));
        }
        if (metricsList.isEmpty()) {
            return new ArrayList<>();
        }
        metricsList.sort((a, b) -> compareDouble(a.staticCost, b.staticCost, a.turnCount, b.turnCount, a.pathLen, b.pathLen));
        PathCandidateMetrics preferred = metricsList.get(0);
        int maxLen = (int) Math.ceil(preferred.pathLen * safeDouble(profileConfig.getS1MaxLenRatio(), 1.15d));
        int maxTurns = preferred.turnCount + safeInt(profileConfig.getS1MaxTurnDiff(), 1);
        List<PathCandidateMetrics> stage1Selected = new ArrayList<>();
        for (PathCandidateMetrics metrics : metricsList) {
            if (metrics.pathLen <= maxLen && metrics.turnCount <= maxTurns) {
                stage1Selected.add(metrics);
            }
        }
        if (stage1Selected.isEmpty()) {
            stage1Selected.addAll(metricsList);
        }
        int topK = safeInt(profileConfig.getS1TopK(), 5);
        if (topK > 0 && stage1Selected.size() > topK) {
            stage1Selected = new ArrayList<>(stage1Selected.subList(0, topK));
        }
        stage1Selected.sort((a, b) -> compareDouble(a.dynamicCost, b.dynamicCost, a.pathLen, b.pathLen, a.turnCount, b.turnCount));
        return stage1Selected.get(0).path;
    }
    private List<List<NavigateNode>> applyRuleFilters(List<List<NavigateNode>> allList,
                                                      StationPathRuleConfig ruleConfig,
                                                      boolean includeWaypoint) {
        if (allList == null || allList.isEmpty()) {
            return new ArrayList<>();
        }
        if (ruleConfig == null) {
            return allList;
        }
        List<List<NavigateNode>> result = new ArrayList<>();
        for (List<NavigateNode> path : allList) {
            if (path == null || path.isEmpty()) {
                continue;
            }
            List<Integer> stationIdList = extractStationIdList(path);
            if (!matchHardConstraint(stationIdList, ruleConfig.getHard())) {
                continue;
            }
            if (includeWaypoint && !matchWaypointConstraint(stationIdList, ruleConfig.getWaypoint())) {
                continue;
            }
            result.add(path);
        }
        return result;
    }
    private boolean matchHardConstraint(List<Integer> stationIdList, StationPathRuleConfig.HardConstraint hard) {
        if (stationIdList == null) {
            return false;
        }
        if (hard == null) {
            return true;
        }
        Set<Integer> stationIdSet = new HashSet<>(stationIdList);
        for (Integer stationId : safeList(hard.getMustPassStations())) {
            if (stationId != null && !stationIdSet.contains(stationId)) {
                return false;
            }
        }
        for (Integer stationId : safeList(hard.getForbidStations())) {
            if (stationId != null && stationIdSet.contains(stationId)) {
                return false;
            }
        }
        for (String edge : safeList(hard.getMustPassEdges())) {
            if (notBlank(edge) && !containsEdge(stationIdList, edge)) {
                return false;
            }
        }
        for (String edge : safeList(hard.getForbidEdges())) {
            if (notBlank(edge) && containsEdge(stationIdList, edge)) {
                return false;
            }
        }
        return true;
    }
    private boolean matchWaypointConstraint(List<Integer> stationIdList, StationPathRuleConfig.WaypointConstraint waypoint) {
        if (waypoint == null || waypoint.getStations() == null || waypoint.getStations().isEmpty()) {
            return true;
        }
        int cursor = 0;
        for (Integer stationId : stationIdList) {
            Integer expected = waypoint.getStations().get(cursor);
            if (expected != null && expected.equals(stationId)) {
                cursor++;
                if (cursor >= waypoint.getStations().size()) {
                    return true;
                }
            }
        }
        return false;
    }
    private boolean hasWaypoint(StationPathRuleConfig ruleConfig) {
        return ruleConfig != null
                && ruleConfig.getWaypoint() != null
                && ruleConfig.getWaypoint().getStations() != null
                && !ruleConfig.getWaypoint().getStations().isEmpty();
    }
    private boolean strictWaypoint(StationPathRuleConfig ruleConfig) {
        return ruleConfig != null
                && ruleConfig.getFallback() != null
                && Boolean.TRUE.equals(ruleConfig.getFallback().getStrictWaypoint());
    }
    private boolean containsEdge(List<Integer> stationIdList, String edgeText) {
        int[] edge = parseEdge(edgeText);
        if (edge == null) {
            return false;
        }
        for (int i = 0; i < stationIdList.size() - 1; i++) {
            Integer current = stationIdList.get(i);
            Integer next = stationIdList.get(i + 1);
            if (current != null && next != null && current == edge[0] && next == edge[1]) {
                return true;
            }
        }
        return false;
    }
    private int[] parseEdge(String edgeText) {
        if (!notBlank(edgeText)) {
            return null;
        }
        String normalized = edgeText.replace(" ", "");
        String[] parts = normalized.split("->");
        if (parts.length != 2) {
            return null;
        }
        try {
            return new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[1])};
        } catch (Exception ignore) {
            return null;
        }
    }
    private PathCandidateMetrics buildCandidateMetrics(List<NavigateNode> path,
                                                       Map<Integer, StationProtocol> statusMap,
                                                       Map<Integer, Double> stationLoopLoadMap,
                                                       StationPathProfileConfig profileConfig,
                                                       StationPathRuleConfig ruleConfig) {
        PathCandidateMetrics metrics = new PathCandidateMetrics();
        metrics.path = path;
        metrics.pathLen = path.size();
        metrics.turnCount = countTurnCount(path);
        metrics.liftTransferCount = countLiftTransferCount(path);
        List<Integer> stationIdList = extractStationIdList(path);
        metrics.busyStationCount = countBusyStationCount(stationIdList, statusMap);
        metrics.runBlockCount = countRunBlockCount(stationIdList, statusMap);
        metrics.loopPenalty = calcLoopPenalty(stationIdList, stationLoopLoadMap);
        metrics.softDeviationCount = calcSoftDeviationCount(stationIdList,
                ruleConfig == null || ruleConfig.getSoft() == null ? null : ruleConfig.getSoft().getPreferredPath());
        double softDeviationWeight = safeDouble(profileConfig.getS1SoftDeviationWeight(), 4.0d);
        if (ruleConfig != null && ruleConfig.getSoft() != null && ruleConfig.getSoft().getDeviationWeight() != null) {
            softDeviationWeight = ruleConfig.getSoft().getDeviationWeight();
        }
        metrics.staticCost =
                safeDouble(profileConfig.getS1LenWeight(), 1.0d) * metrics.pathLen
                        + safeDouble(profileConfig.getS1TurnWeight(), 3.0d) * metrics.turnCount
                        + safeDouble(profileConfig.getS1LiftWeight(), 8.0d) * metrics.liftTransferCount
                        + softDeviationWeight * metrics.softDeviationCount;
        metrics.dynamicCost =
                safeDouble(profileConfig.getS2BusyWeight(), 2.0d) * metrics.busyStationCount
                        + safeDouble(profileConfig.getS2RunBlockWeight(), 10.0d) * metrics.runBlockCount
                        + safeDouble(profileConfig.getS2LoopLoadWeight(), 12.0d) * metrics.loopPenalty;
        return metrics;
    }
    private int countTurnCount(List<NavigateNode> path) {
        if (path == null || path.size() < 3) {
            return 0;
        }
        int count = 0;
        for (int i = 1; i < path.size() - 1; i++) {
            NavigateNode prev = path.get(i - 1);
            NavigateNode next = path.get(i + 1);
            if (prev == null || next == null) {
                continue;
            }
            if (prev.getX() != next.getX() && prev.getY() != next.getY()) {
                count++;
            }
        }
        return count;
    }
    private int countLiftTransferCount(List<NavigateNode> path) {
        int count = 0;
        for (NavigateNode node : safeList(path)) {
            try {
                JSONObject valueObject = JSON.parseObject(node.getNodeValue());
                if (valueObject == null) {
                    continue;
                }
                Object isLiftTransfer = valueObject.get("isLiftTransfer");
                if (isLiftTransfer != null) {
                    String text = String.valueOf(isLiftTransfer);
                    if ("1".equals(text) || "true".equalsIgnoreCase(text)) {
                        count++;
                    }
                }
            } catch (Exception ignore) {
            }
        }
        return count;
    }
    private int countBusyStationCount(List<Integer> stationIdList, Map<Integer, StationProtocol> statusMap) {
        int count = 0;
        for (Integer stationId : stationIdList) {
            StationProtocol protocol = statusMap.get(stationId);
            if (protocol != null && protocol.getTaskNo() != null && protocol.getTaskNo() > 0) {
                count++;
            }
        }
        return count;
    }
    private int countRunBlockCount(List<Integer> stationIdList, Map<Integer, StationProtocol> statusMap) {
        int count = 0;
        for (Integer stationId : stationIdList) {
            StationProtocol protocol = statusMap.get(stationId);
            if (protocol != null && protocol.isRunBlock()) {
                count++;
            }
        }
        return count;
    }
    private double calcLoopPenalty(List<Integer> stationIdList, Map<Integer, Double> stationLoopLoadMap) {
        double maxLoad = 0.0d;
        for (Integer stationId : stationIdList) {
            Double load = stationLoopLoadMap.get(stationId);
            if (load != null && load > maxLoad) {
                maxLoad = load;
            }
        }
        return maxLoad;
    }
    private int calcSoftDeviationCount(List<Integer> stationIdList, List<Integer> preferredPath) {
        if (preferredPath == null || preferredPath.isEmpty() || stationIdList == null || stationIdList.isEmpty()) {
            return 0;
        }
        Set<Integer> preferredSet = new HashSet<>(preferredPath);
        int count = 0;
        for (int i = 1; i < stationIdList.size() - 1; i++) {
            Integer stationId = stationIdList.get(i);
            if (stationId != null && !preferredSet.contains(stationId)) {
                count++;
            }
        }
        return count;
    }
    private List<Integer> extractStationIdList(List<NavigateNode> path) {
        List<Integer> stationIdList = new ArrayList<>();
        Set<Integer> seen = new HashSet<>();
        for (NavigateNode node : safeList(path)) {
            Integer stationId = extractStationId(node);
            if (stationId == null) {
                continue;
            }
            if (seen.add(stationId)) {
                stationIdList.add(stationId);
            }
        }
        return stationIdList;
    }
    private Map<Integer, StationProtocol> loadStationStatusMap() {
        Map<Integer, StationProtocol> statusMap = new HashMap<>();
        try {
            DeviceConfigService deviceConfigService = SpringUtils.getBean(DeviceConfigService.class);
            if (deviceConfigService == null) {
                return statusMap;
            }
            List<DeviceConfig> devpList = deviceConfigService.list(new QueryWrapper<DeviceConfig>()
                    .eq("device_type", String.valueOf(SlaveType.Devp)));
            for (DeviceConfig deviceConfig : devpList) {
                StationThread stationThread = (StationThread) SlaveConnection.get(SlaveType.Devp, deviceConfig.getDeviceNo());
                if (stationThread == null) {
                    continue;
                }
                Map<Integer, StationProtocol> map = stationThread.getStatusMap();
                if (map != null && !map.isEmpty()) {
                    statusMap.putAll(map);
                }
            }
        } catch (Exception ignore) {
        }
        return statusMap;
    }
    private Map<Integer, Double> loadStationLoopLoadMap() {
        Map<Integer, Double> stationLoopLoadMap = new HashMap<>();
        try {
            if (stationCycleCapacityService == null) {
                return stationLoopLoadMap;
            }
            StationCycleCapacityVo capacityVo = stationCycleCapacityService.getLatestSnapshot();
            if (capacityVo == null || capacityVo.getLoopList() == null) {
                return stationLoopLoadMap;
            }
            for (StationCycleLoopVo loopVo : capacityVo.getLoopList()) {
                if (loopVo == null || loopVo.getStationIdList() == null) {
                    continue;
                }
                double currentLoad = loopVo.getCurrentLoad() == null ? 0.0d : loopVo.getCurrentLoad();
                for (Integer stationId : loopVo.getStationIdList()) {
                    if (stationId != null) {
                        stationLoopLoadMap.put(stationId, currentLoad);
                    }
                }
            }
        } catch (Exception ignore) {
        }
        return stationLoopLoadMap;
    }
    private int compareDouble(double left, double right, int thenLeft1, int thenRight1, int thenLeft2, int thenRight2) {
        int result = Double.compare(left, right);
        if (result != 0) {
            return result;
        }
        result = Integer.compare(thenLeft1, thenRight1);
        if (result != 0) {
            return result;
        }
        return Integer.compare(thenLeft2, thenRight2);
    }
    private int safeInt(Integer value, int defaultValue) {
        return value == null ? defaultValue : value;
    }
    private double safeDouble(Double value, double defaultValue) {
        return value == null ? defaultValue : value;
    }
    private boolean notBlank(String text) {
        return text != null && !text.trim().isEmpty();
    }
    private <T> List<T> safeList(List<T> list) {
        return list == null ? Collections.emptyList() : list;
    }
    private static class PathCandidateMetrics {
        private List<NavigateNode> path;
        private int pathLen;
        private int turnCount;
        private int liftTransferCount;
        private int busyStationCount;
        private int runBlockCount;
        private int softDeviationCount;
        private double loopPenalty;
        private double staticCost;
        private double dynamicCost;
    }
    public synchronized List<NavigateNode> findStationBestPath(List<List<NavigateNode>> allList) {
        if (allList == null || allList.isEmpty()) {
            return new ArrayList<>();
src/main/resources/sql/20260313_add_station_path_policy_menu.sql
New file
@@ -0,0 +1,71 @@
-- 将 输送路径策略管理 菜单挂载到:基础资料(优先)或开发专用
-- 说明:执行本脚本后,请在“角色授权”里给对应角色勾选新菜单和“查看”权限。
SET @station_path_policy_parent_id := COALESCE(
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'base' AND level = 1
    ORDER BY id
    LIMIT 1
  ),
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'develop' AND level = 1
    ORDER BY id
    LIMIT 1
  )
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'stationPathPolicy/stationPathPolicy.html', '输送路径策略', @station_path_policy_parent_id, 2, 996, 1
FROM dual
WHERE @station_path_policy_parent_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'stationPathPolicy/stationPathPolicy.html' AND level = 2
  );
UPDATE sys_resource
SET name = '输送路径策略',
    resource_id = @station_path_policy_parent_id,
    level = 2,
    sort = 996,
    status = 1
WHERE code = 'stationPathPolicy/stationPathPolicy.html' AND level = 2;
SET @station_path_policy_id := (
  SELECT id
  FROM sys_resource
  WHERE code = 'stationPathPolicy/stationPathPolicy.html' AND level = 2
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'stationPathPolicy/stationPathPolicy.html#view', '查看', @station_path_policy_id, 3, 1, 1
FROM dual
WHERE @station_path_policy_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'stationPathPolicy/stationPathPolicy.html#view' AND level = 3
  );
UPDATE sys_resource
SET name = '查看',
    resource_id = @station_path_policy_id,
    level = 3,
    sort = 1,
    status = 1
WHERE code = 'stationPathPolicy/stationPathPolicy.html#view' AND level = 3;
SELECT id, code, name, resource_id, level, sort, status
FROM sys_resource
WHERE code IN (
  'stationPathPolicy/stationPathPolicy.html',
  'stationPathPolicy/stationPathPolicy.html#view'
)
ORDER BY level, sort, id;
src/main/resources/sql/20260313_create_station_path_policy_tables.sql
New file
@@ -0,0 +1,65 @@
CREATE TABLE IF NOT EXISTS `asr_bas_station_path_profile` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
  `profile_code` VARCHAR(64) NOT NULL COMMENT '模板编码',
  `profile_name` VARCHAR(128) NOT NULL COMMENT '模板名称',
  `priority` INT NOT NULL DEFAULT 100 COMMENT '优先级,越小越优先',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用',
  `config_json` LONGTEXT NULL COMMENT '模板参数JSON',
  `memo` VARCHAR(255) NULL COMMENT '备注',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_asr_bas_station_path_profile_code` (`profile_code`),
  KEY `idx_asr_bas_station_path_profile_status_priority` (`status`, `priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='输送站点路径评分模板';
CREATE TABLE IF NOT EXISTS `asr_bas_station_path_rule` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
  `rule_code` VARCHAR(64) NOT NULL COMMENT '规则编码',
  `rule_name` VARCHAR(128) NOT NULL COMMENT '规则名称',
  `priority` INT NOT NULL DEFAULT 100 COMMENT '优先级,越小越优先',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用',
  `scene_type` VARCHAR(32) NULL COMMENT '场景类型',
  `start_station_id` INT NULL COMMENT '起点站点ID,空表示通配',
  `end_station_id` INT NULL COMMENT '终点站点ID,空表示通配',
  `profile_code` VARCHAR(64) NULL COMMENT '绑定模板编码',
  `hard_json` LONGTEXT NULL COMMENT '硬约束JSON',
  `waypoint_json` LONGTEXT NULL COMMENT '关键途经点JSON',
  `soft_json` LONGTEXT NULL COMMENT '软偏好JSON',
  `fallback_json` LONGTEXT NULL COMMENT '降级策略JSON',
  `memo` VARCHAR(255) NULL COMMENT '备注',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_asr_bas_station_path_rule_code` (`rule_code`),
  KEY `idx_asr_bas_station_path_rule_match` (`status`, `start_station_id`, `end_station_id`, `priority`),
  KEY `idx_asr_bas_station_path_rule_profile` (`profile_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='输送站点路径人工规则';
INSERT INTO `asr_bas_station_path_profile`
(`profile_code`, `profile_name`, `priority`, `status`, `config_json`, `memo`)
SELECT
  'default',
  '默认两阶段评分模板',
  100,
  1,
  '{"calcMaxDepth":120,"calcMaxPaths":500,"calcMaxCost":300,"s1TopK":5,"s1LenWeight":1.0,"s1TurnWeight":3.0,"s1LiftWeight":8.0,"s1SoftDeviationWeight":4.0,"s1MaxLenRatio":1.15,"s1MaxTurnDiff":1,"s2BusyWeight":2.0,"s2RunBlockWeight":10.0,"s2LoopLoadWeight":12.0}',
  '默认模板,未命中规则时兜底'
FROM dual
WHERE NOT EXISTS (
  SELECT 1 FROM `asr_bas_station_path_profile` WHERE `profile_code` = 'default'
);
INSERT INTO `sys_config`(`name`, `code`, `value`, `type`, `status`, `select_type`)
SELECT '站点路径评分模式', 'stationPathScoreMode', 'legacy', 1, 1, 'String'
FROM dual
WHERE NOT EXISTS (
  SELECT 1 FROM `sys_config` WHERE `code` = 'stationPathScoreMode'
);
INSERT INTO `sys_config`(`name`, `code`, `value`, `type`, `status`, `select_type`)
SELECT '站点路径默认模板编码', 'stationPathDefaultProfileCode', 'default', 1, 1, 'String'
FROM dual
WHERE NOT EXISTS (
  SELECT 1 FROM `sys_config` WHERE `code` = 'stationPathDefaultProfileCode'
);
src/main/webapp/static/js/stationPathPolicy/stationPathPolicy.js
New file
@@ -0,0 +1,1303 @@
function createDefaultProfileConfig() {
    return {
        calcMaxDepth: 120,
        calcMaxPaths: 500,
        calcMaxCost: 300,
        s1TopK: 5,
        s1LenWeight: 1.0,
        s1TurnWeight: 3.0,
        s1LiftWeight: 8.0,
        s1SoftDeviationWeight: 4.0,
        s1MaxLenRatio: 1.15,
        s1MaxTurnDiff: 1,
        s2BusyWeight: 2.0,
        s2RunBlockWeight: 10.0,
        s2LoopLoadWeight: 12.0
    }
}
function createDefaultProfile() {
    return {
        id: null,
        profileCode: '',
        profileName: '',
        priority: 100,
        status: 1,
        memo: '',
        config: createDefaultProfileConfig(),
        _originCode: null
    }
}
function createDefaultRule(defaultProfileCode) {
    return {
        id: null,
        ruleCode: '',
        ruleName: '',
        priority: 100,
        status: 1,
        sceneType: 'station',
        startStationId: null,
        endStationId: null,
        profileCode: defaultProfileCode || 'default',
        memo: '',
        hard: {
            mustPassStations: [],
            forbidStations: [],
            mustPassEdges: [],
            forbidEdges: [],
            mustPassEdgesText: '',
            forbidEdgesText: ''
        },
        waypoint: {
            stations: []
        },
        soft: {
            keyStations: [],
            preferredPath: [],
            deviationWeight: 6.0,
            maxOffPathCount: 2
        },
        fallback: {
            strictWaypoint: false,
            allowSoftDegrade: true
        },
        _originCode: null
    }
}
var app = new Vue({
    el: '#app',
    data: function () {
        return {
            loading: false,
            saving: false,
            scoreMode: 'legacy',
            defaultProfileCode: 'default',
            profiles: [],
            rules: [],
            stations: [],
            levList: [],
            selectedProfileCode: '',
            selectedRuleCode: '',
            profileDialogVisible: false,
            ruleDialogVisible: false,
            profileForm: createDefaultProfile(),
            ruleForm: createDefaultRule('default'),
            previewForm: {
                startStationId: null,
                endStationId: null
            },
            previewLoading: false,
            previewResult: null,
            activeMapLev: null,
            mapContext: {
                lev: null,
                width: 0,
                height: 0,
                nodes: [],
                nodeMap: {}
            },
            mapZoomPercent: 100,
            pickedStationId: null,
            showRuleJson: false,
            showAllPathTags: false,
            softExpandLoading: false,
            mapDragActive: false,
            mapDragMoved: false,
            mapDragStartX: 0,
            mapDragStartY: 0,
            mapDragOriginPanX: 0,
            mapDragOriginPanY: 0,
            suppressNodeClick: false,
            mapPanX: 20,
            mapPanY: 20
        }
    },
    computed: {
        pickedStation: function () {
            return this.findStation(this.pickedStationId)
        },
        hasPickedStation: function () {
            return this.pickedStationId != null
        },
        stationMapById: function () {
            var map = {}
            ;(this.stations || []).forEach(function (station) {
                if (station && station.stationId != null) {
                    map[String(station.stationId)] = station
                }
            })
            return map
        },
        stationOptions: function () {
            return (this.stations || []).map(function (station) {
                return {
                    stationId: station.stationId,
                    label: this.stationOptionLabel(station)
                }
            }.bind(this))
        },
        selectedRule: function () {
            var code = this.selectedRuleCode
            if (!code) {
                return null
            }
            for (var i = 0; i < this.rules.length; i++) {
                if (this.rules[i].ruleCode === code) {
                    return this.rules[i]
                }
            }
            return null
        },
        activeRuleForVisual: function () {
            if (this.ruleDialogVisible && this.ruleForm) {
                return this.ruleForm
            }
            if (this.selectedRule) {
                return this.selectedRule
            }
            if (this.previewResult && this.previewResult.resolvedPolicy && this.previewResult.resolvedPolicy.ruleConfig) {
                return {
                    startStationId: this.previewForm.startStationId,
                    endStationId: this.previewForm.endStationId,
                    hard: this.previewResult.resolvedPolicy.ruleConfig.hard || this.defaultRule().hard,
                    waypoint: this.previewResult.resolvedPolicy.ruleConfig.waypoint || this.defaultRule().waypoint,
                    soft: this.previewResult.resolvedPolicy.ruleConfig.soft || this.defaultRule().soft,
                    fallback: this.previewResult.resolvedPolicy.ruleConfig.fallback || this.defaultRule().fallback
                }
            }
            return null
        },
        previewPathTags: function () {
            var result = this.previewResult
            if (!result || !result.pathStationIds) {
                return []
            }
            return result.pathStationIds.map(function (stationId) {
                return {
                    stationId: stationId,
                    label: this.stationLabel(stationId)
                }
            }.bind(this))
        },
        visiblePreviewPathTags: function () {
            if (this.showAllPathTags) {
                return this.previewPathTags
            }
            return this.previewPathTags.slice(0, 14)
        },
        hiddenPathTagCount: function () {
            return Math.max(this.previewPathTags.length - this.visiblePreviewPathTags.length, 0)
        },
        hasActualPath: function () {
            return !!this.actualPathPolyline
        },
        hasPreviewPath: function () {
            return !!(this.previewResult && this.previewResult.pathStationIds && this.previewResult.pathStationIds.length)
        },
        pathStationLookup: function () {
            return this.buildLookup(this.previewResult && this.previewResult.pathStationIds)
        },
        preferredStationLookup: function () {
            return this.buildLookup(this.activeRuleForVisual && this.activeRuleForVisual.soft ? this.activeRuleForVisual.soft.preferredPath : [])
        },
        waypointStationLookup: function () {
            return this.buildLookup(this.activeRuleForVisual && this.activeRuleForVisual.waypoint ? this.activeRuleForVisual.waypoint.stations : [])
        },
        forbidStationLookup: function () {
            return this.buildLookup(this.activeRuleForVisual && this.activeRuleForVisual.hard ? this.activeRuleForVisual.hard.forbidStations : [])
        },
        mustPassStationLookup: function () {
            return this.buildLookup(this.activeRuleForVisual && this.activeRuleForVisual.hard ? this.activeRuleForVisual.hard.mustPassStations : [])
        },
        renderedMapNodes: function () {
            var stationMap = this.stationMapById
            var pickedId = String(this.pickedStationId == null ? '' : this.pickedStationId)
            var startId = String(this.previewForm.startStationId == null ? '' : this.previewForm.startStationId)
            var endId = String(this.previewForm.endStationId == null ? '' : this.previewForm.endStationId)
            var pathLookup = this.pathStationLookup
            var preferredLookup = this.preferredStationLookup
            var waypointLookup = this.waypointStationLookup
            var forbidLookup = this.forbidStationLookup
            var mustPassLookup = this.mustPassStationLookup
            return (this.mapContext.nodes || []).map(function (node) {
                var stationId = node.stationId
                var key = String(stationId == null ? '' : stationId)
                var classes = []
                if (pickedId && pickedId === key) {
                    classes.push('is-picked')
                }
                if (startId && startId === key) {
                    classes.push('is-start')
                }
                if (endId && endId === key) {
                    classes.push('is-end')
                }
                if (pathLookup[key]) {
                    classes.push('is-path')
                }
                if (preferredLookup[key]) {
                    classes.push('is-preferred')
                }
                if (waypointLookup[key]) {
                    classes.push('is-waypoint')
                }
                if (forbidLookup[key]) {
                    classes.push('is-forbid')
                }
                if (mustPassLookup[key]) {
                    classes.push('is-must-pass')
                }
                var station = stationMap[key]
                return {
                    stationId: stationId,
                    x: node.x,
                    y: node.y,
                    left: node.x + 'px',
                    top: node.y + 'px',
                    classes: classes,
                    title: station ? this.stationOptionLabel(station) : String(stationId || ''),
                    showLabel: !!(startId === key || endId === key || pathLookup[key] || waypointLookup[key] || forbidLookup[key] || mustPassLookup[key] || pickedId === key)
                }
            }.bind(this))
        },
        mapStageStyle: function () {
            return {
                width: this.mapContext.width + 'px',
                height: this.mapContext.height + 'px',
                transform: 'translate(' + this.mapPanX + 'px, ' + this.mapPanY + 'px) scale(' + (this.mapZoomPercent / 100) + ')'
            }
        },
        actualPathPolyline: function () {
            var stationIds = this.previewResult && this.previewResult.pathStationIds ? this.previewResult.pathStationIds : []
            return this.buildPolyline(stationIds)
        },
        preferredPathPolyline: function () {
            var rule = this.activeRuleForVisual
            var preferredPath = rule && rule.soft ? rule.soft.preferredPath : []
            return this.buildPolyline(preferredPath)
        },
        activeRulePreviewJson: function () {
            if (!this.activeRuleForVisual) {
                return ''
            }
            return JSON.stringify(this.sanitizeRuleForSave(this.activeRuleForVisual), null, 2)
        },
        ruleDialogPickedHint: function () {
            if (!this.ruleDialogVisible) {
                return ''
            }
            if (!this.pickedStation) {
                return '规则弹窗开启时,仍可在右侧地图点击站点,再一键加入必经/禁用/途经/偏好。'
            }
            return '当前地图选中:' + this.stationOptionLabel(this.pickedStation)
        }
    },
    mounted: function () {
        this.loadData()
    },
    beforeDestroy: function () {
        this.detachMapDragListeners()
    },
    methods: {
        loadData: function () {
            var that = this
            this.loading = true
            $.ajax({
                url: baseUrl + '/basStationPathPolicy/data/auth',
                method: 'GET',
                headers: { token: localStorage.getItem('token') },
                success: function (res) {
                    that.loading = false
                    if (res.code !== 200) {
                        that.$message.error('加载失败: ' + res.msg)
                        return
                    }
                    var data = res.data || {}
                    that.scoreMode = data.scoreMode || 'legacy'
                    that.defaultProfileCode = data.defaultProfileCode || 'default'
                    that.showRuleJson = false
                    that.showAllPathTags = false
                    that.stations = (data.stations || []).sort(function (a, b) {
                        if ((a.stationLev || 0) !== (b.stationLev || 0)) {
                            return (a.stationLev || 0) - (b.stationLev || 0)
                        }
                        return (a.stationId || 0) - (b.stationId || 0)
                    })
                    that.levList = data.levList || []
                    that.profiles = (data.profiles || []).map(that.normalizeProfile)
                    that.rules = (data.rules || []).map(that.normalizeRule)
                    if (!that.defaultProfileCode && that.profiles.length) {
                        that.defaultProfileCode = that.profiles[0].profileCode
                    }
                    if (!that.selectedProfileCode && that.profiles.length) {
                        that.selectedProfileCode = that.defaultProfileCode || that.profiles[0].profileCode
                    }
                    if (!that.selectedRuleCode && that.rules.length) {
                        that.selectedRuleCode = that.rules[0].ruleCode
                    }
                    if (that.selectedRule) {
                        that.loadMapByRule(that.selectedRule)
                    } else if (that.levList.length && !that.activeMapLev) {
                        that.loadMapByLev(that.levList[0])
                    }
                },
                error: function () {
                    that.loading = false
                    that.$message.error('加载请求异常')
                }
            })
        },
        saveAll: function () {
            if (!this.profiles.length) {
                this.$message.warning('至少需要保留一个模板')
                return
            }
            var hasDefault = this.profiles.some(function (item) {
                return item.profileCode === this.defaultProfileCode
            }.bind(this))
            if (!hasDefault) {
                this.$message.warning('默认模板编码没有对应模板')
                return
            }
            var payload = {
                scoreMode: this.scoreMode,
                defaultProfileCode: this.defaultProfileCode,
                profiles: this.profiles.map(this.sanitizeProfileForSave),
                rules: this.rules.map(this.sanitizeRuleForSave)
            }
            var that = this
            this.saving = true
            $.ajax({
                url: baseUrl + '/basStationPathPolicy/save/auth',
                method: 'POST',
                headers: { token: localStorage.getItem('token') },
                contentType: 'application/json',
                data: JSON.stringify(payload),
                success: function (res) {
                    that.saving = false
                    if (res.code !== 200) {
                    that.$message.error('保存失败: ' + res.msg)
                        return
                    }
                    that.$message.success('保存成功')
                    that.loadData()
                },
                error: function () {
                    that.saving = false
                    that.$message.error('保存请求异常')
                }
            })
        },
        openProfileDialog: function (item) {
            this.profileForm = item ? this.cloneProfileModel(item) : this.defaultProfile()
            this.profileDialogVisible = true
        },
        confirmProfileDialog: function () {
            var form = this.profileForm
            if (this.isBlank(form.profileCode)) {
                this.$message.warning('模板编码不能为空')
                return
            }
            if (this.isBlank(form.profileName)) {
                this.$message.warning('模板名称不能为空')
                return
            }
            var existsIndex = this.findProfileIndex(form.profileCode)
            if (existsIndex >= 0 && (!form._originCode || form._originCode !== form.profileCode)) {
                this.$message.warning('模板编码已存在')
                return
            }
            var profile = this.cloneProfileModel(form)
            delete profile._originCode
            if (form._originCode) {
                var originIndex = this.findProfileIndex(form._originCode)
                if (originIndex >= 0) {
                    this.$set(this.profiles, originIndex, profile)
                    if (this.defaultProfileCode === form._originCode) {
                        this.defaultProfileCode = profile.profileCode
                    }
                    if (this.selectedProfileCode === form._originCode) {
                        this.selectedProfileCode = profile.profileCode
                    }
                    this.rules.forEach(function (rule) {
                        if (rule.profileCode === form._originCode) {
                            rule.profileCode = profile.profileCode
                        }
                    })
                }
            } else {
                this.profiles.push(profile)
                this.selectedProfileCode = profile.profileCode
                if (!this.defaultProfileCode) {
                    this.defaultProfileCode = profile.profileCode
                }
            }
            this.profileDialogVisible = false
        },
        cloneProfile: function (item) {
            var copy = this.cloneProfileModel(item)
            copy.profileCode = item.profileCode + '_copy'
            copy.profileName = item.profileName + ' - 副本'
            copy._originCode = null
            this.profileForm = copy
            this.profileDialogVisible = true
        },
        removeProfile: function (item) {
            if (!item) {
                return
            }
            if (this.profiles.length <= 1) {
                this.$message.warning('至少保留一个模板')
                return
            }
            var used = this.rules.some(function (rule) {
                return rule.profileCode === item.profileCode
            })
            if (used) {
                this.$message.warning('该模板仍被规则引用,不能删除')
                return
            }
            var that = this
            this.$confirm('确认删除模板 ' + item.profileCode + ' 吗?', '提示', { type: 'warning' })
                .then(function () {
                    that.profiles = that.profiles.filter(function (profile) {
                        return profile.profileCode !== item.profileCode
                    })
                    if (that.defaultProfileCode === item.profileCode) {
                        that.defaultProfileCode = that.profiles[0] ? that.profiles[0].profileCode : ''
                    }
                    if (that.selectedProfileCode === item.profileCode) {
                        that.selectedProfileCode = that.defaultProfileCode
                    }
                })
                .catch(function () {})
        },
        openRuleDialog: function (item) {
            this.ruleForm = item ? this.cloneRuleModel(item) : this.defaultRule()
            this.ruleDialogVisible = true
        },
        confirmRuleDialog: function () {
            var form = this.ruleForm
            if (this.isBlank(form.ruleCode)) {
                this.$message.warning('规则编码不能为空')
                return
            }
            if (this.isBlank(form.ruleName)) {
                this.$message.warning('规则名称不能为空')
                return
            }
            var existsIndex = this.findRuleIndex(form.ruleCode)
            if (existsIndex >= 0 && (!form._originCode || form._originCode !== form.ruleCode)) {
                this.$message.warning('规则编码已存在')
                return
            }
            var rule = this.cloneRuleModel(form)
            delete rule._originCode
            if (form._originCode) {
                var originIndex = this.findRuleIndex(form._originCode)
                if (originIndex >= 0) {
                    this.$set(this.rules, originIndex, rule)
                    if (this.selectedRuleCode === form._originCode) {
                        this.selectedRuleCode = rule.ruleCode
                    }
                }
            } else {
                this.rules.push(rule)
                this.selectedRuleCode = rule.ruleCode
            }
            this.ruleDialogVisible = false
            this.loadMapByRule(rule)
        },
        importPreviewPathToRule: function () {
            if (!this.hasPreviewPath) {
                this.$message.warning('请先在右侧完成一次路径预览')
                return
            }
            if (!this.ruleForm || !this.ruleForm.soft) {
                return
            }
            this.ruleForm.soft.preferredPath = (this.previewResult.pathStationIds || []).slice()
            this.ruleForm.soft.keyStations = []
            if (!this.ruleForm.startStationId && this.previewForm.startStationId) {
                this.ruleForm.startStationId = this.previewForm.startStationId
            }
            if (!this.ruleForm.endStationId && this.previewForm.endStationId) {
                this.ruleForm.endStationId = this.previewForm.endStationId
            }
            this.$message.success('已导入当前预览路径,共 ' + this.ruleForm.soft.preferredPath.length + ' 个站点')
        },
        expandRuleSoftPreferredPath: function () {
            if (this.softExpandLoading) {
                return
            }
            if (!this.ruleForm || !this.ruleForm.soft) {
                return
            }
            var startStationId = this.toNumberSafe(this.ruleForm.startStationId) || this.toNumberSafe(this.previewForm.startStationId)
            var endStationId = this.toNumberSafe(this.ruleForm.endStationId) || this.toNumberSafe(this.previewForm.endStationId)
            if (startStationId == null || endStationId == null) {
                this.$message.warning('请先为规则设置起点和终点,或先在右侧预览一条路径')
                return
            }
            var keyStations = this.uniqueNumbers((this.ruleForm.soft.keyStations || []).slice())
            var that = this
            this.softExpandLoading = true
            $.ajax({
                url: baseUrl + '/basStationPathPolicy/expandSoftPath/auth',
                method: 'POST',
                headers: { token: localStorage.getItem('token') },
                contentType: 'application/json',
                data: JSON.stringify({
                    startStationId: startStationId,
                    endStationId: endStationId,
                    keyStations: keyStations
                }),
                success: function (res) {
                    that.softExpandLoading = false
                    if (res.code !== 200) {
                        that.$message.error('展开失败: ' + res.msg)
                        return
                    }
                    var data = res.data || {}
                    var pathStationIds = data.pathStationIds || []
                    if (!pathStationIds.length) {
                        that.$message.warning('没有生成可用的软偏好路径')
                        return
                    }
                    that.ruleForm.startStationId = startStationId
                    that.ruleForm.endStationId = endStationId
                    that.ruleForm.soft.keyStations = keyStations
                    that.ruleForm.soft.preferredPath = pathStationIds.slice()
                    that.$message.success(keyStations.length
                        ? '已按关键点展开完整软偏好路径'
                        : '已按起终点生成完整软偏好路径')
                },
                error: function () {
                    that.softExpandLoading = false
                    that.$message.error('展开软偏好路径请求异常')
                }
            })
        },
        clearSoftPreferredPath: function () {
            if (!this.ruleForm || !this.ruleForm.soft) {
                return
            }
            this.ruleForm.soft.keyStations = []
            this.ruleForm.soft.preferredPath = []
        },
        cloneRule: function (item) {
            var copy = this.cloneRuleModel(item)
            copy.ruleCode = item.ruleCode + '_copy'
            copy.ruleName = item.ruleName + ' - 副本'
            copy._originCode = null
            this.ruleForm = copy
            this.ruleDialogVisible = true
        },
        removeRule: function (item) {
            var that = this
            this.$confirm('确认删除规则 ' + item.ruleCode + ' 吗?', '提示', { type: 'warning' })
                .then(function () {
                    that.rules = that.rules.filter(function (rule) {
                        return rule.ruleCode !== item.ruleCode
                    })
                    if (that.selectedRuleCode === item.ruleCode) {
                        that.selectedRuleCode = that.rules[0] ? that.rules[0].ruleCode : ''
                    }
                })
                .catch(function () {})
        },
        selectRule: function (item) {
            this.selectedRuleCode = item.ruleCode
            this.loadMapByRule(item)
        },
        previewRule: function (item) {
            this.selectRule(item)
            this.previewForm.startStationId = item.startStationId
            this.previewForm.endStationId = item.endStationId
            this.showRuleJson = false
            this.showAllPathTags = false
            if (item.startStationId && item.endStationId) {
                this.loadPreview()
            }
        },
        loadPreview: function () {
            if (!this.previewForm.startStationId || !this.previewForm.endStationId) {
                this.$message.warning('请选择起点和终点')
                return
            }
            var that = this
            this.previewLoading = true
            $.ajax({
                url: baseUrl + '/basStationPathPolicy/preview/auth',
                method: 'GET',
                headers: { token: localStorage.getItem('token') },
                data: {
                    startStationId: this.previewForm.startStationId,
                    endStationId: this.previewForm.endStationId
                },
                success: function (res) {
                    that.previewLoading = false
                    if (res.code !== 200) {
                        that.$message.error('预览失败: ' + res.msg)
                        return
                    }
                    that.showRuleJson = false
                    that.showAllPathTags = false
                    that.previewResult = res.data || null
                    if (that.previewResult && that.previewResult.lev) {
                        that.activeMapLev = that.previewResult.lev
                    }
                    if (that.previewResult && that.previewResult.mapData) {
                        that.applyMapData(that.previewResult.mapData, that.previewResult.lev)
                    } else if (that.activeMapLev && that.mapContext.lev !== that.activeMapLev) {
                        that.loadMapByLev(that.activeMapLev)
                    }
                    that.$nextTick(function () {
                        that.centerOnPath()
                    })
                    if (!that.hasActualPath) {
                        that.$message.warning('当前起终点未计算到可行路径,请检查规则或楼层地图')
                    }
                },
                error: function () {
                    that.previewLoading = false
                    that.$message.error('预览请求异常')
                }
            })
        },
        loadMapByRule: function (rule) {
            if (!rule) {
                return
            }
            var station = this.findStation(rule.startStationId || rule.endStationId)
            if (station && station.stationLev && this.mapContext.lev !== station.stationLev) {
                this.loadMapByLev(station.stationLev)
            }
        },
        loadMapByLev: function (lev) {
            if (!lev) {
                return
            }
            if (this.mapContext.lev === lev && this.mapContext.nodes && this.mapContext.nodes.length) {
                return
            }
            var that = this
            $.ajax({
                url: baseUrl + '/basMap/lev/' + lev + '/auth',
                method: 'GET',
                headers: { token: localStorage.getItem('token') },
                success: function (res) {
                    if (res.code !== 200) {
                        that.$message.error('加载楼层地图失败: ' + res.msg)
                        return
                    }
                    that.applyMapData(res.data, lev)
                },
                error: function () {
                    that.$message.error('加载楼层地图请求异常')
                }
            })
        },
        applyMapData: function (mapData, lev) {
            var parsed = mapData
            if (typeof mapData === 'string') {
                try {
                    parsed = JSON.parse(mapData)
                } catch (e) {
                    parsed = []
                }
            }
            if (!Array.isArray(parsed)) {
                this.mapContext = { lev: lev, width: 0, height: 0, nodes: [], nodeMap: {} }
                return
            }
            var cellStep = 26
            var margin = 22
            var nodes = []
            var nodeMap = {}
            var rows = parsed.length
            var cols = 0
            for (var r = 0; r < parsed.length; r++) {
                var row = parsed[r] || []
                cols = Math.max(cols, row.length)
                for (var c = 0; c < row.length; c++) {
                    var cell = row[c] || {}
                    var type = cell.type
                    var mergeType = cell.mergeType
                    if (!(type === 'devp' || (type === 'merge' && mergeType === 'devp'))) {
                        continue
                    }
                    var value = this.parseJson(cell.value)
                    var stationId = value ? value.stationId : null
                    if (!stationId) {
                        continue
                    }
                    var node = {
                        stationId: stationId,
                        row: r,
                        col: c,
                        x: margin + c * cellStep,
                        y: margin + r * cellStep,
                        stationAlias: value.stationAlias || '',
                        isLiftTransfer: value.isLiftTransfer === 1 || value.isLiftTransfer === true
                    }
                    nodes.push(node)
                    nodeMap[String(stationId)] = node
                }
            }
            this.mapContext = {
                lev: lev,
                width: margin * 2 + Math.max(cols - 1, 0) * cellStep + 40,
                height: margin * 2 + Math.max(rows - 1, 0) * cellStep + 40,
                nodes: nodes,
                nodeMap: nodeMap
            }
            this.fitMap()
        },
        fitMap: function () {
            var wrap = this.$refs.mapCanvasWrap
            if (!wrap || !this.mapContext.width || !this.mapContext.height) {
                return
            }
            var bounds = this.getMapContentBounds()
            var contentWidth = Math.max((bounds.maxX - bounds.minX), 1)
            var contentHeight = Math.max((bounds.maxY - bounds.minY), 1)
            var usableWidth = Math.max(wrap.clientWidth - 30, 320)
            var usableHeight = Math.max(wrap.clientHeight - 30, 240)
            var scaleX = usableWidth / contentWidth
            var scaleY = usableHeight / contentHeight
            var scale = Math.min(scaleX, scaleY, 1.7)
            var zoomPercent = Math.max(60, Math.min(220, Math.floor(scale * 100)))
            var centerX = (bounds.minX + bounds.maxX) / 2
            var centerY = (bounds.minY + bounds.maxY) / 2
            this.mapZoomPercent = zoomPercent
            this.mapPanX = Math.round(wrap.clientWidth / 2 - centerX * (zoomPercent / 100))
            this.mapPanY = Math.round(wrap.clientHeight / 2 - centerY * (zoomPercent / 100))
        },
        centerOnPath: function () {
            var wrap = this.$refs.mapCanvasWrap
            var stage = this.$refs.mapStage
            if (!wrap || !stage || !this.actualPathPolyline) {
                return
            }
            var stationIds = this.previewResult && this.previewResult.pathStationIds ? this.previewResult.pathStationIds : []
            var points = this.resolvePoints(stationIds)
            if (!points.length) {
                return
            }
            var minX = points[0].x
            var maxX = points[0].x
            var minY = points[0].y
            var maxY = points[0].y
            points.forEach(function (point) {
                minX = Math.min(minX, point.x)
                maxX = Math.max(maxX, point.x)
                minY = Math.min(minY, point.y)
                maxY = Math.max(maxY, point.y)
            })
            var scale = this.mapZoomPercent / 100
            this.mapPanX = Math.round(wrap.clientWidth / 2 - ((minX + maxX) / 2) * scale)
            this.mapPanY = Math.round(wrap.clientHeight / 2 - ((minY + maxY) / 2) * scale)
        },
        resetPreview: function () {
            this.previewForm.startStationId = null
            this.previewForm.endStationId = null
            this.previewResult = null
            this.pickedStationId = null
            this.showRuleJson = false
            this.showAllPathTags = false
        },
        updateMapZoom: function (nextPercent) {
            var wrap = this.$refs.mapCanvasWrap
            var zoomPercent = this.toNumberSafe(nextPercent)
            if (zoomPercent == null) {
                return
            }
            zoomPercent = Math.max(60, Math.min(220, zoomPercent))
            if (!wrap || !this.mapContext.width || !this.mapContext.height) {
                this.mapZoomPercent = zoomPercent
                return
            }
            this.setMapZoomAroundPoint(zoomPercent, wrap.clientWidth / 2, wrap.clientHeight / 2)
        },
        setMapZoomAroundPoint: function (nextPercent, anchorX, anchorY) {
            var currentPercent = this.mapZoomPercent
            if (!currentPercent || currentPercent === nextPercent) {
                this.mapZoomPercent = nextPercent
                return
            }
            var currentScale = currentPercent / 100
            var nextScale = nextPercent / 100
            if (!currentScale || !nextScale) {
                this.mapZoomPercent = nextPercent
                return
            }
            var mapX = (anchorX - this.mapPanX) / currentScale
            var mapY = (anchorY - this.mapPanY) / currentScale
            this.mapZoomPercent = nextPercent
            this.mapPanX = Math.round(anchorX - mapX * nextScale)
            this.mapPanY = Math.round(anchorY - mapY * nextScale)
        },
        handleMapWheel: function (event) {
            if (!this.mapContext.nodes.length) {
                return
            }
            if (event.ctrlKey || event.metaKey) {
                var wrap = this.$refs.mapCanvasWrap
                if (!wrap) {
                    return
                }
                var rect = wrap.getBoundingClientRect()
                var delta = event.deltaY < 0 ? 10 : -10
                var nextPercent = Math.max(60, Math.min(220, this.mapZoomPercent + delta))
                this.setMapZoomAroundPoint(nextPercent, event.clientX - rect.left, event.clientY - rect.top)
                return
            }
            this.mapPanX -= event.deltaX
            this.mapPanY -= event.deltaY
        },
        beginMapDrag: function (event) {
            var wrap = this.$refs.mapCanvasWrap
            if (!wrap || !this.mapContext.nodes.length) {
                return
            }
            if (event && event.button != null && event.button !== 0) {
                return
            }
            this.mapDragActive = true
            this.mapDragMoved = false
            this.mapDragStartX = event.clientX
            this.mapDragStartY = event.clientY
            this.mapDragOriginPanX = this.mapPanX
            this.mapDragOriginPanY = this.mapPanY
            document.addEventListener('mousemove', this.handleMapDragMove)
            document.addEventListener('mouseup', this.endMapDrag)
            if (event.preventDefault) {
                event.preventDefault()
            }
        },
        handleMapDragMove: function (event) {
            if (!this.mapDragActive) {
                return
            }
            var wrap = this.$refs.mapCanvasWrap
            if (!wrap) {
                this.endMapDrag()
                return
            }
            var deltaX = event.clientX - this.mapDragStartX
            var deltaY = event.clientY - this.mapDragStartY
            if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
                this.mapDragMoved = true
            }
            this.mapPanX = this.mapDragOriginPanX + deltaX
            this.mapPanY = this.mapDragOriginPanY + deltaY
        },
        endMapDrag: function () {
            if (!this.mapDragActive) {
                this.detachMapDragListeners()
                return
            }
            this.mapDragActive = false
            this.detachMapDragListeners()
            if (this.mapDragMoved) {
                this.suppressNodeClick = true
                var that = this
                window.setTimeout(function () {
                    that.suppressNodeClick = false
                }, 0)
            }
        },
        detachMapDragListeners: function () {
            document.removeEventListener('mousemove', this.handleMapDragMove)
            document.removeEventListener('mouseup', this.endMapDrag)
        },
        pickNode: function (node) {
            if (this.suppressNodeClick) {
                return
            }
            this.pickedStationId = node.stationId
        },
        applyPickedStation: function (field) {
            if (!this.pickedStationId) {
                return
            }
            if (field === 'start') {
                this.previewForm.startStationId = this.pickedStationId
            } else if (field === 'end') {
                this.previewForm.endStationId = this.pickedStationId
            } else if (field === 'ruleStart' && this.ruleForm) {
                this.ruleForm.startStationId = this.pickedStationId
            } else if (field === 'ruleEnd' && this.ruleForm) {
                this.ruleForm.endStationId = this.pickedStationId
            } else if (field === 'mustPass' && this.ruleForm) {
                this.pushUniqueStation(this.ruleForm.hard.mustPassStations, this.pickedStationId)
            } else if (field === 'forbid' && this.ruleForm) {
                this.pushUniqueStation(this.ruleForm.hard.forbidStations, this.pickedStationId)
            } else if (field === 'waypoint' && this.ruleForm) {
                this.pushUniqueStation(this.ruleForm.waypoint.stations, this.pickedStationId)
            } else if (field === 'preferred' && this.ruleForm) {
                this.pushUniqueStation(this.ruleForm.soft.preferredPath, this.pickedStationId)
            }
        },
        pushUniqueStation: function (list, stationId) {
            if (!Array.isArray(list)) {
                return
            }
            var value = this.toNumberSafe(stationId)
            if (value == null) {
                return
            }
            var exists = list.some(function (item) {
                return String(item) === String(value)
            })
            if (!exists) {
                list.push(value)
            }
        },
        moveListItem: function (list, index, offset) {
            if (!Array.isArray(list)) {
                return
            }
            var targetIndex = index + offset
            if (index < 0 || targetIndex < 0 || index >= list.length || targetIndex >= list.length) {
                return
            }
            var moved = list.splice(index, 1)[0]
            list.splice(targetIndex, 0, moved)
        },
        removeListItem: function (list, index) {
            if (!Array.isArray(list) || index < 0 || index >= list.length) {
                return
            }
            list.splice(index, 1)
        },
        buildLookup: function (list) {
            var lookup = {}
            ;(list || []).forEach(function (item) {
                if (item != null && item !== '') {
                    lookup[String(item)] = true
                }
            })
            return lookup
        },
        nodeStyle: function (node) {
            return {
                left: node.x + 'px',
                top: node.y + 'px'
            }
        },
        nodeClasses: function (node) {
            var stationId = node.stationId
            var classes = []
            if (String(this.pickedStationId || '') === String(stationId)) {
                classes.push('is-picked')
            }
            if (this.previewForm.startStationId === stationId) {
                classes.push('is-start')
            }
            if (this.previewForm.endStationId === stationId) {
                classes.push('is-end')
            }
            if (this.pathStationSet()[stationId]) {
                classes.push('is-path')
            }
            if (this.preferredStationSet()[stationId]) {
                classes.push('is-preferred')
            }
            if (this.waypointStationSet()[stationId]) {
                classes.push('is-waypoint')
            }
            if (this.forbidStationSet()[stationId]) {
                classes.push('is-forbid')
            }
            if (this.mustPassStationSet()[stationId]) {
                classes.push('is-must-pass')
            }
            return classes
        },
        showNodeLabel: function (node) {
            var stationId = node.stationId
            return !!(
                this.previewForm.startStationId === stationId
                || this.previewForm.endStationId === stationId
                || this.pathStationSet()[stationId]
                || this.waypointStationSet()[stationId]
                || this.forbidStationSet()[stationId]
                || this.mustPassStationSet()[stationId]
                || String(this.pickedStationId || '') === String(stationId)
            )
        },
        stationNodeTitle: function (node) {
            return this.stationLabel(node.stationId)
        },
        stationOptionLabel: function (station) {
            if (!station) {
                return ''
            }
            var alias = station.stationAlias ? ' · ' + station.stationAlias : ''
            return 'L' + (station.stationLev || '-') + ' · ' + station.stationId + alias
        },
        stationLabel: function (stationId) {
            var station = this.findStation(stationId)
            return station ? this.stationOptionLabel(station) : String(stationId || '')
        },
        findStation: function (stationId) {
            if (stationId == null) {
                return null
            }
            return this.stationMapById[String(stationId)] || null
        },
        routeLabel: function (rule) {
            if (!rule.startStationId && !rule.endStationId) {
                return '通配规则'
            }
            return (rule.startStationId || '*') + ' → ' + (rule.endStationId || '*')
        },
        ruleSummaryCount: function (hard) {
            hard = hard || {}
            return (hard.mustPassStations || []).length
                + (hard.forbidStations || []).length
                + (hard.mustPassEdges || []).length
                + (hard.forbidEdges || []).length
        },
        pathStationSet: function () {
            return this.pathStationLookup
        },
        preferredStationSet: function () {
            return this.preferredStationLookup
        },
        waypointStationSet: function () {
            return this.waypointStationLookup
        },
        forbidStationSet: function () {
            return this.forbidStationLookup
        },
        mustPassStationSet: function () {
            return this.mustPassStationLookup
        },
        buildPolyline: function (stationIds) {
            var points = this.resolvePoints(stationIds)
            if (!points.length) {
                return ''
            }
            return points.map(function (point) {
                return point.x + ',' + point.y
            }).join(' ')
        },
        resolvePoints: function (stationIds) {
            var points = []
            var map = this.mapContext.nodeMap || {}
            ;(stationIds || []).forEach(function (stationId) {
                var node = map[String(stationId)]
                if (node) {
                    points.push({ x: node.x, y: node.y })
                }
            })
            return points
        },
        getMapContentBounds: function () {
            var nodes = this.mapContext && this.mapContext.nodes ? this.mapContext.nodes : []
            if (!nodes.length) {
                return {
                    minX: 0,
                    maxX: this.mapContext.width || 0,
                    minY: 0,
                    maxY: this.mapContext.height || 0
                }
            }
            var minX = null
            var maxX = null
            var minY = null
            var maxY = null
            nodes.forEach(function (node) {
                if (!node) {
                    return
                }
                minX = minX == null ? node.x : Math.min(minX, node.x)
                maxX = maxX == null ? node.x : Math.max(maxX, node.x)
                minY = minY == null ? node.y : Math.min(minY, node.y)
                maxY = maxY == null ? node.y : Math.max(maxY, node.y)
            })
            var padding = 48
            return {
                minX: Math.max((minX == null ? 0 : minX) - padding, 0),
                maxX: Math.min((maxX == null ? 0 : maxX) + padding, this.mapContext.width || Number.MAX_SAFE_INTEGER),
                minY: Math.max((minY == null ? 0 : minY) - padding, 0),
                maxY: Math.min((maxY == null ? 0 : maxY) + padding, this.mapContext.height || Number.MAX_SAFE_INTEGER)
            }
        },
        defaultProfileConfig: function () {
            return createDefaultProfileConfig()
        },
        defaultProfile: function () {
            return createDefaultProfile()
        },
        defaultRule: function () {
            return createDefaultRule(this.defaultProfileCode || 'default')
        },
        normalizeProfile: function (raw) {
            var config = Object.assign({}, this.defaultProfileConfig(), this.parseJson(raw.configJson) || raw.config || {})
            return {
                id: raw.id || null,
                profileCode: raw.profileCode || '',
                profileName: raw.profileName || raw.profileCode || '',
                priority: raw.priority == null ? 100 : Number(raw.priority),
                status: raw.status == null ? 1 : Number(raw.status),
                memo: raw.memo || '',
                config: config
            }
        },
        normalizeRule: function (raw) {
            var rule = this.defaultRule()
            var hard = Object.assign({}, rule.hard, this.parseJson(raw.hardJson) || raw.hard || {})
            var waypoint = Object.assign({}, rule.waypoint, this.parseJson(raw.waypointJson) || raw.waypoint || {})
            var soft = Object.assign({}, rule.soft, this.parseJson(raw.softJson) || raw.soft || {})
            var fallback = Object.assign({}, rule.fallback, this.parseJson(raw.fallbackJson) || raw.fallback || {})
            hard.mustPassEdgesText = (hard.mustPassEdges || []).join('\n')
            hard.forbidEdgesText = (hard.forbidEdges || []).join('\n')
            rule.id = raw.id || null
            rule.ruleCode = raw.ruleCode || ''
            rule.ruleName = raw.ruleName || raw.ruleCode || ''
            rule.priority = raw.priority == null ? 100 : Number(raw.priority)
            rule.status = raw.status == null ? 1 : Number(raw.status)
            rule.sceneType = raw.sceneType || 'station'
            rule.startStationId = raw.startStationId == null ? null : Number(raw.startStationId)
            rule.endStationId = raw.endStationId == null ? null : Number(raw.endStationId)
            rule.profileCode = raw.profileCode || ''
            rule.memo = raw.memo || ''
            rule.hard = hard
            rule.waypoint = waypoint
            rule.soft = soft
            rule.fallback = fallback
            return rule
        },
        cloneProfileModel: function (item) {
            var model = this.normalizeProfile(item)
            model._originCode = item.profileCode || item._originCode || null
            return JSON.parse(JSON.stringify(model))
        },
        cloneRuleModel: function (item) {
            var model = this.normalizeRule(item)
            model._originCode = item.ruleCode || item._originCode || null
            return JSON.parse(JSON.stringify(model))
        },
        sanitizeProfileForSave: function (item) {
            return {
                id: item.id || null,
                profileCode: item.profileCode,
                profileName: item.profileName,
                priority: Number(item.priority || 100),
                status: Number(item.status || 0),
                memo: item.memo || '',
                config: Object.assign({}, item.config || {})
            }
        },
        sanitizeRuleForSave: function (item) {
            var hard = Object.assign({}, item.hard || {})
            hard.mustPassEdges = this.parseLines(hard.mustPassEdgesText || hard.mustPassEdges || [])
            hard.forbidEdges = this.parseLines(hard.forbidEdgesText || hard.forbidEdges || [])
            delete hard.mustPassEdgesText
            delete hard.forbidEdgesText
            return {
                id: item.id || null,
                ruleCode: item.ruleCode,
                ruleName: item.ruleName,
                priority: Number(item.priority || 100),
                status: Number(item.status || 0),
                sceneType: item.sceneType || '',
                startStationId: item.startStationId == null ? null : Number(item.startStationId),
                endStationId: item.endStationId == null ? null : Number(item.endStationId),
                profileCode: item.profileCode || '',
                memo: item.memo || '',
                hard: {
                    mustPassStations: this.uniqueNumbers(hard.mustPassStations || []),
                    forbidStations: this.uniqueNumbers(hard.forbidStations || []),
                    mustPassEdges: hard.mustPassEdges,
                    forbidEdges: hard.forbidEdges
                },
                waypoint: {
                    stations: this.uniqueNumbers((item.waypoint && item.waypoint.stations) || [])
                },
                soft: {
                    keyStations: this.uniqueNumbers((item.soft && item.soft.keyStations) || []),
                    preferredPath: this.uniqueNumbers((item.soft && item.soft.preferredPath) || []),
                    deviationWeight: this.toNumberSafe(item.soft && item.soft.deviationWeight) || 0,
                    maxOffPathCount: this.toNumberSafe(item.soft && item.soft.maxOffPathCount) || 0
                },
                fallback: {
                    strictWaypoint: !!(item.fallback && item.fallback.strictWaypoint),
                    allowSoftDegrade: !(item.fallback && item.fallback.allowSoftDegrade === false)
                }
            }
        },
        findProfileIndex: function (profileCode) {
            for (var i = 0; i < this.profiles.length; i++) {
                if (this.profiles[i].profileCode === profileCode) {
                    return i
                }
            }
            return -1
        },
        findRuleIndex: function (ruleCode) {
            for (var i = 0; i < this.rules.length; i++) {
                if (this.rules[i].ruleCode === ruleCode) {
                    return i
                }
            }
            return -1
        },
        parseJson: function (value) {
            if (!value) {
                return null
            }
            if (typeof value === 'object') {
                return value
            }
            try {
                return JSON.parse(value)
            } catch (e) {
                return null
            }
        },
        parseLines: function (value) {
            if (Array.isArray(value)) {
                return value.filter(function (item) { return !!String(item || '').trim() })
            }
            return String(value || '')
                .split('\n')
                .map(function (item) { return item.trim() })
                .filter(function (item) { return !!item })
        },
        toNumberSafe: function (value) {
            if (value == null || value === '') {
                return null
            }
            var num = Number(value)
            return isNaN(num) ? null : num
        },
        notNull: function (value) {
            return value != null
        },
        uniqueNumbers: function (list) {
            var result = []
            var seen = {}
            ;(list || []).forEach(function (item) {
                var num = this.toNumberSafe(item)
                if (num == null) {
                    return
                }
                if (!seen[String(num)]) {
                    seen[String(num)] = true
                    result.push(num)
                }
            }.bind(this))
            return result
        },
        isBlank: function (text) {
            return text == null || String(text).trim() === ''
        }
    }
})
src/main/webapp/views/stationPathPolicy/stationPathPolicy.html
New file
@@ -0,0 +1,1300 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>输送路径策略管理</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <link rel="stylesheet" href="../../static/vue/element/element.css">
    <link rel="stylesheet" href="../../static/css/cool.css">
    <style>
        :root {
            --page-bg:
                radial-gradient(1200px 560px at -10% -20%, rgba(53, 117, 200, 0.14), transparent 58%),
                radial-gradient(900px 420px at 110% -10%, rgba(38, 164, 138, 0.12), transparent 56%),
                linear-gradient(180deg, #eff4fa 0%, #f7fafc 100%);
            --card-bg: rgba(255, 255, 255, 0.94);
            --card-border: rgba(216, 226, 238, 0.95);
            --text-main: #223449;
            --text-sub: #607286;
            --primary: #2f79d6;
            --accent: #20a98a;
            --warn: #ef8b3b;
            --danger: #dc5c5c;
            --path: #1f9f89;
            --preferred: #2f79d6;
            --forbid: #d9534f;
            --must-pass: #9b59b6;
            --waypoint: #ff9f1c;
        }
        [v-cloak] { display: none; }
        html, body {
            margin: 0;
            min-height: 100%;
            height: auto;
            font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
            background: var(--page-bg);
            color: var(--text-main);
        }
        .page-shell {
            min-height: 100%;
            height: auto;
            box-sizing: border-box;
            width: min(1880px, calc(100% - 24px));
            margin: 0 auto;
            padding: 20px 8px 24px;
            display: flex;
            flex-direction: column;
            gap: 18px;
        }
        .hero-card,
        .panel-card {
            background: var(--card-bg);
            border: 1px solid var(--card-border);
            border-radius: 24px;
            box-shadow: 0 16px 32px rgba(39, 62, 92, 0.08);
        }
        .hero-card {
            padding: 24px 28px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 20px;
            flex-wrap: wrap;
        }
        .hero-title {
            display: flex;
            flex-direction: column;
            gap: 6px;
        }
        .hero-title h1 {
            margin: 0;
            font-size: 24px;
            font-weight: 700;
            letter-spacing: 0.4px;
        }
        .hero-actions {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
            justify-content: flex-end;
        }
        .hero-grid {
            display: grid;
            grid-template-columns: minmax(340px, 380px) minmax(0, 1fr);
            grid-template-areas:
                "profile rule"
                "profile preview";
            gap: 16px;
            align-items: start;
        }
        .profile-panel {
            grid-area: profile;
            min-width: 0;
        }
        .rule-panel {
            grid-area: rule;
            min-width: 0;
        }
        .panel-card {
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }
        .preview-panel {
            grid-area: preview;
            min-width: 0;
        }
        .panel-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 10px;
            padding: 20px 22px 16px;
            border-bottom: 1px solid rgba(221, 230, 239, 0.94);
        }
        .panel-head h2 {
            margin: 0;
            font-size: 17px;
            font-weight: 700;
        }
        .panel-body {
            padding: 18px 20px 20px;
            flex: 0 0 auto;
            overflow: visible;
        }
        .setting-grid {
            display: grid;
            grid-template-columns: 1fr;
            gap: 12px;
        }
        .hint-card {
            border-radius: 18px;
            background: linear-gradient(180deg, rgba(243, 248, 255, 0.94) 0%, rgba(248, 251, 255, 0.9) 100%);
            border: 1px solid rgba(212, 223, 236, 0.92);
            padding: 14px 16px;
        }
        .hint-card strong {
            display: block;
            margin-bottom: 6px;
            font-size: 13px;
        }
        .hint-card span {
            color: var(--text-sub);
            font-size: 12px;
            line-height: 1.7;
        }
        .stat-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 10px;
        }
        .stat-card {
            border: 1px solid rgba(217, 227, 236, 0.94);
            background: rgba(255, 255, 255, 0.95);
            border-radius: 18px;
            padding: 16px;
        }
        .stat-card .label {
            color: var(--text-sub);
            font-size: 12px;
        }
        .stat-card .value {
            margin-top: 6px;
            font-size: 24px;
            font-weight: 700;
        }
        .entity-list {
            display: flex;
            flex-direction: column;
            gap: 10px;
        }
        .entity-empty.el-empty {
            padding: 18px 0 4px;
        }
        .entity-empty .el-empty__image {
            height: 96px;
        }
        .entity-empty .el-empty__description p {
            color: var(--text-sub);
            font-size: 13px;
        }
        .entity-item {
            border: 1px solid rgba(215, 226, 238, 0.94);
            background: rgba(255, 255, 255, 0.96);
            border-radius: 18px;
            padding: 16px 16px 14px;
            transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
        }
        .entity-item.is-active {
            border-color: rgba(47, 121, 214, 0.65);
            box-shadow: 0 10px 24px rgba(47, 121, 214, 0.12);
            transform: translateY(-1px);
        }
        .entity-item-head {
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 10px;
        }
        .entity-title {
            font-size: 14px;
            font-weight: 700;
            line-height: 1.5;
        }
        .entity-meta {
            display: flex;
            flex-wrap: wrap;
            gap: 6px;
            margin-top: 8px;
        }
        .meta-pill {
            display: inline-flex;
            align-items: center;
            padding: 4px 8px;
            font-size: 12px;
            border-radius: 999px;
            background: rgba(242, 246, 252, 0.94);
            color: var(--text-sub);
            border: 1px solid rgba(220, 229, 238, 0.9);
        }
        .entity-desc {
            margin-top: 10px;
            color: var(--text-sub);
            font-size: 12px;
            line-height: 1.7;
        }
        .entity-actions {
            margin-top: 12px;
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
        }
        .entity-actions .el-button {
            padding: 7px 11px;
            border-radius: 10px;
        }
        .empty-shell {
            border: 1px dashed rgba(193, 207, 221, 0.96);
            background: rgba(248, 251, 254, 0.84);
            border-radius: 18px;
            padding: 22px 16px;
        }
        .preview-toolbar {
            display: flex;
            flex-direction: column;
            gap: 12px;
            margin-bottom: 0;
        }
        .preview-toolbar-row {
            display: grid;
            gap: 12px;
            align-items: center;
        }
        .preview-toolbar-row-main {
            grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(180px, 220px);
        }
        .preview-toolbar-row-secondary {
            grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
        }
        .preview-toolbar-actions {
            display: flex;
            gap: 10px;
            justify-content: flex-end;
            align-items: center;
            flex-wrap: wrap;
        }
        .preview-zoom-card {
            display: flex;
            align-items: center;
            gap: 14px;
            padding: 10px 14px;
            border-radius: 18px;
            background: linear-gradient(180deg, rgba(247, 250, 255, 0.96) 0%, rgba(243, 248, 254, 0.96) 100%);
            border: 1px solid rgba(217, 227, 236, 0.96);
        }
        .preview-zoom-meta {
            min-width: 74px;
        }
        .preview-zoom-meta strong {
            display: block;
            font-size: 12px;
            margin-bottom: 2px;
        }
        .preview-zoom-meta span {
            font-size: 12px;
            color: var(--text-sub);
        }
        .preview-panel-body {
            display: flex;
            flex-direction: column;
            gap: 14px;
        }
        .preview-info-grid {
            display: grid;
            grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.95fr);
            gap: 12px;
            align-items: start;
        }
        .preview-summary-card {
            border-radius: 16px;
            border: 1px solid rgba(217, 227, 236, 0.96);
            background: rgba(255, 255, 255, 0.96);
            padding: 12px 14px;
        }
        .preview-summary {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            margin-bottom: 0;
        }
        .preview-summary-actions {
            margin-top: 10px;
            display: flex;
            justify-content: flex-end;
        }
        .summary-chip {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 7px 10px;
            border-radius: 999px;
            background: rgba(244, 248, 252, 0.96);
            border: 1px solid rgba(220, 229, 238, 0.96);
            font-size: 12px;
        }
        .summary-chip strong {
            color: var(--text-main);
        }
        .route-strip {
            border-radius: 18px;
            border: 1px solid rgba(217, 227, 236, 0.94);
            background: rgba(255, 255, 255, 0.94);
            padding: 12px;
            margin-bottom: 12px;
        }
        .route-strip-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
            margin-bottom: 10px;
        }
        .route-tag-row {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            max-height: 98px;
            overflow-y: auto;
            padding-right: 4px;
        }
        .route-tag {
            border-radius: 999px;
            padding: 6px 10px;
            background: rgba(47, 121, 214, 0.08);
            color: #245a95;
            font-size: 12px;
            border: 1px solid rgba(47, 121, 214, 0.12);
        }
        .route-strip-more {
            margin-top: 8px;
            color: var(--text-sub);
            font-size: 12px;
        }
        .map-legend {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            margin-bottom: 10px;
        }
        .legend-item {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            font-size: 12px;
            color: var(--text-sub);
        }
        .legend-dot {
            width: 10px;
            height: 10px;
            border-radius: 50%;
            display: inline-block;
        }
        .map-shell {
            position: relative;
            flex: 1;
            min-height: 620px;
            border-radius: 22px;
            border: 1px solid rgba(216, 226, 238, 0.95);
            background:
                linear-gradient(180deg, rgba(251, 253, 255, 0.96) 0%, rgba(247, 250, 253, 0.94) 100%);
            overflow: hidden;
        }
        .map-toolbar {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 10px;
            padding: 12px 14px;
            border-bottom: 1px solid rgba(221, 230, 239, 0.94);
            background: rgba(249, 251, 254, 0.9);
        }
        .map-toolbar-left,
        .map-toolbar-right {
            display: flex;
            align-items: center;
            gap: 10px;
            flex-wrap: wrap;
        }
        .map-canvas-wrap {
            position: absolute;
            top: 56px;
            right: 0;
            bottom: 0;
            left: 0;
            overflow: hidden;
            padding: 20px;
            box-sizing: border-box;
            cursor: grab;
            user-select: none;
            touch-action: none;
        }
        .map-stage {
            position: absolute;
            top: 0;
            left: 0;
            transform-origin: left top;
            will-change: transform;
        }
        .map-stage svg {
            position: absolute;
            top: 0;
            left: 0;
            overflow: visible;
        }
        .map-node {
            position: absolute;
            width: 18px;
            height: 18px;
            margin-left: -9px;
            margin-top: -9px;
            border-radius: 50%;
            border: 2px solid rgba(165, 181, 198, 0.88);
            background: rgba(255, 255, 255, 0.95);
            cursor: pointer;
            transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
            display: flex;
            align-items: center;
            justify-content: center;
            box-sizing: border-box;
        }
        .map-node:hover {
            transform: scale(1.14);
            box-shadow: 0 8px 18px rgba(58, 82, 111, 0.16);
            z-index: 4;
        }
        .map-canvas-wrap.is-dragging {
            cursor: grabbing;
        }
        .map-canvas-wrap.is-dragging .map-node {
            cursor: grabbing;
        }
        .map-node.is-picked {
            width: 26px;
            height: 26px;
            margin-left: -13px;
            margin-top: -13px;
            border-color: #101828;
            box-shadow: 0 10px 24px rgba(16, 24, 40, 0.16);
            z-index: 6;
        }
        .map-node.is-path {
            border-color: rgba(31, 159, 137, 0.92);
            background: rgba(31, 159, 137, 0.12);
        }
        .map-node.is-preferred {
            border-color: rgba(47, 121, 214, 0.92);
            background: rgba(47, 121, 214, 0.09);
        }
        .map-node.is-waypoint {
            box-shadow: 0 0 0 4px rgba(255, 159, 28, 0.16);
        }
        .map-node.is-forbid {
            border-color: rgba(220, 92, 92, 0.94);
            background: rgba(220, 92, 92, 0.12);
        }
        .map-node.is-must-pass {
            box-shadow: 0 0 0 4px rgba(155, 89, 182, 0.14);
        }
        .map-node.is-start,
        .map-node.is-end {
            width: 28px;
            height: 28px;
            margin-left: -14px;
            margin-top: -14px;
        }
        .map-node.is-start {
            border-color: rgba(47, 121, 214, 0.92);
            background: rgba(47, 121, 214, 0.18);
        }
        .map-node.is-end {
            border-color: rgba(32, 169, 138, 0.92);
            background: rgba(32, 169, 138, 0.16);
        }
        .map-node-label {
            position: absolute;
            top: 16px;
            left: 50%;
            transform: translateX(-50%);
            white-space: nowrap;
            font-size: 11px;
            padding: 2px 6px;
            border-radius: 999px;
            background: rgba(16, 24, 40, 0.76);
            color: #fff;
            pointer-events: none;
        }
        .rule-json {
            margin-top: 12px;
            border-radius: 18px;
            background: rgba(16, 24, 40, 0.92);
            color: #d6e5ff;
            padding: 12px 14px;
            font-family: Menlo, Monaco, Consolas, monospace;
            font-size: 12px;
            line-height: 1.7;
            max-height: 220px;
            overflow: auto;
            white-space: pre-wrap;
            word-break: break-all;
        }
        .mono {
            font-family: Menlo, Monaco, Consolas, monospace;
        }
        .sequence-card {
            margin-top: 10px;
            border-radius: 16px;
            border: 1px solid rgba(218, 228, 238, 0.96);
            background: rgba(255, 255, 255, 0.92);
            padding: 8px;
        }
        .sequence-row {
            display: grid;
            grid-template-columns: 28px minmax(0, 1fr) auto;
            align-items: center;
            gap: 10px;
            border-radius: 12px;
            padding: 6px 8px;
        }
        .sequence-row + .sequence-row {
            margin-top: 6px;
        }
        .sequence-row:hover {
            background: rgba(243, 247, 252, 0.92);
        }
        .sequence-index {
            width: 24px;
            height: 24px;
            border-radius: 50%;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            font-size: 12px;
            color: #245a95;
            background: rgba(47, 121, 214, 0.08);
            border: 1px solid rgba(47, 121, 214, 0.14);
        }
        .sequence-label {
            min-width: 0;
            font-size: 12px;
            line-height: 1.6;
            color: var(--text-main);
            word-break: break-all;
        }
        .sequence-actions {
            display: inline-flex;
            align-items: center;
            gap: 4px;
        }
        .sequence-actions .el-button {
            padding: 5px 7px;
            border-radius: 10px;
        }
        .dialog-picked-bar {
            margin: 0 0 12px;
            border-radius: 16px;
            border: 1px solid rgba(217, 227, 236, 0.96);
            background: rgba(247, 250, 254, 0.95);
            padding: 10px 12px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            flex-wrap: wrap;
        }
        .dialog-picked-bar .help {
            color: var(--text-sub);
            font-size: 12px;
            line-height: 1.6;
        }
        .dialog-panel .el-dialog {
            border-radius: 24px;
            overflow: hidden;
        }
        .dialog-panel .el-dialog__header {
            padding: 22px 24px 14px;
            background: linear-gradient(180deg, #f7fbff 0%, #f2f7fb 100%);
            border-bottom: 1px solid rgba(223, 231, 240, 0.94);
        }
        .dialog-panel .el-dialog__body {
            padding: 18px 24px 10px;
        }
        .dialog-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 12px;
        }
        .dialog-grid .span-2 {
            grid-column: span 2;
        }
        .section-card {
            border: 1px solid rgba(219, 229, 238, 0.96);
            border-radius: 18px;
            padding: 12px 14px 2px;
            background: rgba(250, 252, 254, 0.9);
            margin-bottom: 12px;
        }
        .section-card h3 {
            margin: 0 0 10px;
            font-size: 13px;
            font-weight: 700;
        }
        .section-title-row {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            flex-wrap: wrap;
            margin-bottom: 10px;
        }
        .section-title-row h3 {
            margin-bottom: 0;
        }
        .section-inline-actions {
            display: flex;
            gap: 8px;
            flex-wrap: wrap;
        }
        .section-help {
            color: var(--text-sub);
            font-size: 12px;
            margin: -2px 0 10px;
            line-height: 1.6;
        }
        .selected-station-bar {
            border-radius: 16px;
            border: 1px solid rgba(217, 227, 236, 0.96);
            background: rgba(255, 255, 255, 0.95);
            padding: 10px 12px;
            margin-bottom: 12px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            flex-wrap: wrap;
        }
        @media (max-width: 1480px) {
            .hero-grid {
                grid-template-columns: minmax(320px, 360px) minmax(0, 1fr);
            }
            .preview-info-grid {
                grid-template-columns: 1fr;
            }
        }
        @media (max-width: 1280px) {
            .hero-grid {
                grid-template-columns: 1fr;
                grid-template-areas:
                    "profile"
                    "rule"
                    "preview";
            }
            .map-shell {
                min-height: 560px;
            }
            .preview-toolbar-row,
            .preview-toolbar-row-main,
            .preview-toolbar-row-secondary {
                grid-template-columns: 1fr;
            }
            .preview-toolbar-actions {
                justify-content: flex-start;
            }
        }
    </style>
</head>
<body>
<div id="app" v-cloak class="page-shell">
    <div class="hero-card">
        <div class="hero-title">
            <h1>输送路径策略管理</h1>
        </div>
        <div class="hero-actions">
            <el-button icon="el-icon-refresh" @click="loadData">刷新</el-button>
            <el-button type="primary" icon="el-icon-check" :loading="saving" @click="saveAll">保存全部</el-button>
        </div>
    </div>
    <div class="hero-grid">
        <div class="panel-card profile-panel">
            <div class="panel-head">
                <div><h2>模板与模式</h2></div>
                <el-button type="primary" plain size="small" icon="el-icon-plus" @click="openProfileDialog()">新增模板</el-button>
            </div>
            <div class="panel-body">
                <div class="setting-grid">
                    <div class="section-card">
                        <h3>全局开关</h3>
                        <el-form label-position="top">
                            <div class="dialog-grid">
                                <el-form-item label="评分模式" class="span-2">
                                    <el-radio-group v-model="scoreMode">
                                        <el-radio-button label="legacy">legacy</el-radio-button>
                                        <el-radio-button label="twoStage">twoStage</el-radio-button>
                                    </el-radio-group>
                                </el-form-item>
                                <el-form-item label="默认模板" class="span-2">
                                    <el-select v-model="defaultProfileCode" placeholder="请选择默认模板" filterable style="width: 100%;">
                                        <el-option v-for="item in profiles" :key="item.profileCode" :label="item.profileName + ' (' + item.profileCode + ')'" :value="item.profileCode"></el-option>
                                    </el-select>
                                </el-form-item>
                            </div>
                        </el-form>
                    </div>
                    <div class="stat-grid">
                        <div class="stat-card">
                            <div class="label">模板数量</div>
                            <div class="value">{{ profiles.length }}</div>
                        </div>
                        <div class="stat-card">
                            <div class="label">规则数量</div>
                            <div class="value">{{ rules.length }}</div>
                        </div>
                    </div>
                    <div class="entity-list" v-if="profiles.length">
                        <div class="entity-item" v-for="item in profiles" :key="item.profileCode" :class="{ 'is-active': selectedProfileCode === item.profileCode }" @click="selectedProfileCode = item.profileCode">
                            <div class="entity-item-head">
                                <div>
                                    <div class="entity-title">{{ item.profileName }}</div>
                                    <div class="entity-meta">
                                        <span class="meta-pill mono">{{ item.profileCode }}</span>
                                        <span class="meta-pill">优先级 {{ item.priority }}</span>
                                        <span class="meta-pill">{{ item.status === 1 ? '启用' : '禁用' }}</span>
                                    </div>
                                </div>
                                <el-tag size="mini" :type="defaultProfileCode === item.profileCode ? 'success' : 'info'">{{ defaultProfileCode === item.profileCode ? '默认' : '模板' }}</el-tag>
                            </div>
                            <div class="entity-desc">
                                S1: 长度 {{ item.config.s1LenWeight }} / 拐点 {{ item.config.s1TurnWeight }} / 顶升 {{ item.config.s1LiftWeight }}<br>
                                S2: 忙站 {{ item.config.s2BusyWeight }} / 堵塞 {{ item.config.s2RunBlockWeight }} / 环线 {{ item.config.s2LoopLoadWeight }}
                            </div>
                            <div class="entity-actions">
                                <el-button size="mini" @click.stop="openProfileDialog(item)">编辑</el-button>
                                <el-button size="mini" type="primary" plain @click.stop="cloneProfile(item)">复制</el-button>
                                <el-button size="mini" type="danger" plain @click.stop="removeProfile(item)">删除</el-button>
                            </div>
                        </div>
                    </div>
                    <el-empty v-else class="entity-empty" description="还没有路径模板"></el-empty>
                </div>
            </div>
        </div>
        <div class="panel-card rule-panel">
            <div class="panel-head">
                <div><h2>人工规则</h2></div>
                <el-button type="primary" plain size="small" icon="el-icon-plus" @click="openRuleDialog()">新增规则</el-button>
            </div>
            <div class="panel-body">
                <div class="entity-list" v-if="rules.length">
                    <div class="entity-item" v-for="item in rules" :key="item.ruleCode" :class="{ 'is-active': selectedRuleCode === item.ruleCode }" @click="selectRule(item)">
                        <div class="entity-item-head">
                            <div>
                                <div class="entity-title">{{ item.ruleName }}</div>
                                <div class="entity-meta">
                                    <span class="meta-pill mono">{{ item.ruleCode }}</span>
                                    <span class="meta-pill">优先级 {{ item.priority }}</span>
                                    <span class="meta-pill">{{ item.status === 1 ? '启用' : '禁用' }}</span>
                                    <span class="meta-pill">{{ item.profileCode || '未绑定模板' }}</span>
                                </div>
                            </div>
                            <el-tag size="mini" :type="item.startStationId && item.endStationId ? 'success' : 'warning'">{{ routeLabel(item) }}</el-tag>
                        </div>
                        <div class="entity-desc">
                            硬约束 {{ ruleSummaryCount(item.hard) }} 项 · 途经点 {{ (item.waypoint.stations || []).length }} 个 · 软偏好 {{ (item.soft.preferredPath || []).length }} 段
                        </div>
                        <div class="entity-actions">
                            <el-button size="mini" @click.stop="openRuleDialog(item)">编辑</el-button>
                            <el-button size="mini" type="primary" plain @click.stop="cloneRule(item)">复制</el-button>
                            <el-button size="mini" @click.stop="previewRule(item)" :disabled="!item.startStationId || !item.endStationId">试算</el-button>
                            <el-button size="mini" type="danger" plain @click.stop="removeRule(item)">删除</el-button>
                        </div>
                    </div>
                </div>
                <el-empty v-else class="entity-empty" description="还没有人工规则"></el-empty>
            </div>
        </div>
        <div class="panel-card preview-panel">
            <div class="panel-head">
                <div><h2>可视化预览</h2></div>
            </div>
            <div class="panel-body preview-panel-body">
                <div class="preview-toolbar">
                    <div class="preview-toolbar-row preview-toolbar-row-main">
                        <el-select v-model="previewForm.startStationId" filterable clearable placeholder="起点站点">
                            <el-option v-for="item in stationOptions" :key="'s-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                        <el-select v-model="previewForm.endStationId" filterable clearable placeholder="终点站点">
                            <el-option v-for="item in stationOptions" :key="'e-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                        <el-select v-model="activeMapLev" placeholder="楼层" clearable @change="loadMapByLev" :disabled="previewLoading">
                            <el-option v-for="lev in levList" :key="lev" :label="'楼层 ' + lev" :value="lev"></el-option>
                        </el-select>
                    </div>
                    <div class="preview-toolbar-row preview-toolbar-row-secondary">
                        <div class="preview-zoom-card">
                            <div class="preview-zoom-meta">
                                <strong>地图缩放</strong>
                                <span>{{ mapZoomPercent }}%</span>
                            </div>
                            <el-slider :value="mapZoomPercent" @input="updateMapZoom" :min="60" :max="220" :step="10" :show-tooltip="false" style="flex: 1;"></el-slider>
                        </div>
                        <div class="preview-toolbar-actions">
                            <el-button @click="fitMap" :disabled="!mapContext.nodes.length">适配地图</el-button>
                            <el-button @click="resetPreview">清空</el-button>
                            <el-button type="primary" :loading="previewLoading" @click="loadPreview" :disabled="!previewForm.startStationId || !previewForm.endStationId">解析预览</el-button>
                        </div>
                    </div>
                </div>
                <div class="preview-info-grid">
                    <div class="selected-station-bar">
                        <div>
                            <strong>当前选中站点:</strong>
                            <span v-if="pickedStation">{{ stationOptionLabel(pickedStation) }}</span>
                            <span v-else style="color: var(--text-sub);">点击地图上的输送站点</span>
                        </div>
                        <div style="display: flex; gap: 8px; flex-wrap: wrap;">
                            <el-button size="mini" :disabled="!pickedStation" @click="applyPickedStation('start')">设为预览起点</el-button>
                            <el-button size="mini" :disabled="!pickedStation" @click="applyPickedStation('end')">设为预览终点</el-button>
                        </div>
                    </div>
                    <div class="preview-summary-card">
                        <div class="preview-summary">
                            <span class="summary-chip"><strong>模式</strong> {{ scoreMode }}</span>
                            <span class="summary-chip"><strong>默认模板</strong> {{ defaultProfileCode || '-' }}</span>
                            <span class="summary-chip" v-if="previewResult"><strong>命中模板</strong> {{ previewResult.resolvedPolicy && previewResult.resolvedPolicy.profileEntity ? previewResult.resolvedPolicy.profileEntity.profileCode : '-' }}</span>
                            <span class="summary-chip" v-if="previewResult"><strong>命中规则</strong> {{ previewResult.resolvedPolicy && previewResult.resolvedPolicy.ruleEntity ? previewResult.resolvedPolicy.ruleEntity.ruleCode : '无' }}</span>
                            <span class="summary-chip" v-if="previewResult"><strong>路径长度</strong> {{ previewResult.pathLength }}</span>
                            <span class="summary-chip" v-if="previewResult"><strong>拐点数</strong> {{ previewResult.turnCount }}</span>
                            <span class="summary-chip" v-if="previewResult"><strong>顶升点</strong> {{ previewResult.liftTransferCount }}</span>
                        </div>
                        <div class="preview-summary-actions" v-if="activeRulePreviewJson">
                            <el-button type="text" @click="showRuleJson = !showRuleJson">{{ showRuleJson ? '收起规则 JSON' : '查看规则 JSON' }}</el-button>
                        </div>
                    </div>
                </div>
                <div class="route-strip" v-if="previewPathTags.length">
                    <div class="route-strip-head">
                        <strong>路径站点序列</strong>
                        <div style="display: flex; align-items: center; gap: 12px;">
                            <span style="color: var(--text-sub); font-size: 12px;">{{ previewPathTags.length }} 个站点</span>
                            <el-button type="text" v-if="hiddenPathTagCount || showAllPathTags" @click="showAllPathTags = !showAllPathTags">{{ showAllPathTags ? '收起' : '展开全部' }}</el-button>
                        </div>
                    </div>
                    <div class="route-tag-row">
                        <span class="route-tag" v-for="item in visiblePreviewPathTags" :key="'path-' + item.stationId">{{ item.label }}</span>
                    </div>
                    <div class="route-strip-more" v-if="hiddenPathTagCount && !showAllPathTags">还有 {{ hiddenPathTagCount }} 个站点未展开</div>
                </div>
                <div class="map-legend">
                    <span class="legend-item"><i class="legend-dot" style="background: rgba(255,255,255,0.95); border: 2px solid rgba(165,181,198,0.88);"></i>输送站点</span>
                    <span class="legend-item"><i class="legend-dot" style="background: rgba(31,159,137,0.12); border: 2px solid rgba(31,159,137,0.92);"></i>实际路径</span>
                    <span class="legend-item"><i class="legend-dot" style="background: rgba(47,121,214,0.09); border: 2px solid rgba(47,121,214,0.92);"></i>软偏好</span>
                    <span class="legend-item"><i class="legend-dot" style="background: rgba(220,92,92,0.12); border: 2px solid rgba(220,92,92,0.94);"></i>禁用站点</span>
                    <span class="legend-item"><i class="legend-dot" style="background: rgba(255,159,28,0.16); border: 2px solid rgba(255,159,28,0.92);"></i>途经点</span>
                    <span class="legend-item"><i class="legend-dot" style="background: rgba(155,89,182,0.14); border: 2px solid rgba(155,89,182,0.92);"></i>必经站点</span>
                </div>
                <div class="map-shell">
                    <div class="map-toolbar">
                        <div class="map-toolbar-left">
                            <strong>楼层 {{ activeMapLev || '-' }}</strong>
                            <span style="color: var(--text-sub); font-size: 12px;">节点 {{ mapContext.nodes.length }}</span>
                        </div>
                        <div class="map-toolbar-right">
                            <el-button size="mini" @click="centerOnPath" :disabled="!hasActualPath">聚焦路径</el-button>
                        </div>
                    </div>
                    <div class="map-canvas-wrap"
                         ref="mapCanvasWrap"
                         :class="{ 'is-dragging': mapDragActive }"
                         @mousedown.left="beginMapDrag"
                         @wheel.prevent="handleMapWheel"
                         @dragstart.prevent>
                        <div v-if="!mapContext.nodes.length" class="empty-shell" style="margin: 14px;">请选择楼层或执行一次路径预览。</div>
                        <div v-else class="map-stage" :style="mapStageStyle" ref="mapStage">
                            <svg :width="mapContext.width" :height="mapContext.height">
                                <polyline v-if="preferredPathPolyline" :points="preferredPathPolyline" fill="none" stroke="var(--preferred)" stroke-width="4" stroke-dasharray="10 8" opacity="0.72"></polyline>
                                <polyline v-if="actualPathPolyline" :points="actualPathPolyline" fill="none" stroke="var(--path)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0.92"></polyline>
                            </svg>
                            <div v-for="node in renderedMapNodes"
                                 :key="'node-' + node.stationId"
                                 class="map-node"
                                 :class="node.classes"
                                 :style="{ left: node.left, top: node.top }"
                                 :title="node.title"
                                 @click="pickNode(node)">
                                <div class="map-node-label" v-if="node.showLabel">{{ node.stationId }}</div>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="hint-card" v-if="previewResult && !hasActualPath" style="margin-top: 12px;">
                    <strong>当前无可行路径</strong>
                    <span>系统已经解析出命中规则和模板,但在现有地图、硬约束、途经点与实时条件下没有得到可行路线。可以先检查禁用站点、途经点顺序和楼层选择。</span>
                </div>
                <div class="rule-json" v-if="showRuleJson && activeRulePreviewJson">
{{ activeRulePreviewJson }}
                </div>
            </div>
        </div>
    </div>
    <el-dialog title="路径模板" :visible.sync="profileDialogVisible" width="820px" class="dialog-panel" append-to-body :destroy-on-close="true">
        <div class="dialog-grid">
            <el-form label-position="top" label-width="120px" style="width: 100%;">
                <div class="section-card">
                    <h3>基础信息</h3>
                    <div class="dialog-grid">
                        <el-form-item label="模板编码">
                            <el-input v-model.trim="profileForm.profileCode" placeholder="如 default / loop_safe"></el-input>
                        </el-form-item>
                        <el-form-item label="模板名称">
                            <el-input v-model.trim="profileForm.profileName" placeholder="模板名称"></el-input>
                        </el-form-item>
                        <el-form-item label="优先级">
                            <el-input-number v-model="profileForm.priority" :min="1" :max="9999" style="width: 100%;"></el-input-number>
                        </el-form-item>
                        <el-form-item label="状态">
                            <el-switch v-model="profileForm.status" :active-value="1" :inactive-value="0"></el-switch>
                        </el-form-item>
                        <el-form-item label="备注" class="span-2">
                            <el-input v-model.trim="profileForm.memo" placeholder="可填写适用场景"></el-input>
                        </el-form-item>
                    </div>
                </div>
                <div class="section-card">
                    <h3>候选生成</h3>
                    <div class="dialog-grid">
                        <el-form-item label="最大深度"><el-input-number v-model="profileForm.config.calcMaxDepth" :min="20" :max="500" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="最大路径数"><el-input-number v-model="profileForm.config.calcMaxPaths" :min="10" :max="3000" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="最大代价"><el-input-number v-model="profileForm.config.calcMaxCost" :min="10" :max="5000" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="S1 保留 TopK"><el-input-number v-model="profileForm.config.s1TopK" :min="1" :max="50" style="width: 100%;"></el-input-number></el-form-item>
                    </div>
                </div>
                <div class="section-card">
                    <h3>第一阶段静态评分</h3>
                    <div class="dialog-grid">
                        <el-form-item label="长度权重"><el-input-number v-model="profileForm.config.s1LenWeight" :min="0" :step="0.5" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="拐点权重"><el-input-number v-model="profileForm.config.s1TurnWeight" :min="0" :step="0.5" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="顶升权重"><el-input-number v-model="profileForm.config.s1LiftWeight" :min="0" :step="0.5" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="偏离人工路径权重"><el-input-number v-model="profileForm.config.s1SoftDeviationWeight" :min="0" :step="0.5" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="长度放宽比例"><el-input-number v-model="profileForm.config.s1MaxLenRatio" :min="1" :step="0.05" :precision="2" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="允许多拐点数"><el-input-number v-model="profileForm.config.s1MaxTurnDiff" :min="0" :max="20" style="width: 100%;"></el-input-number></el-form-item>
                    </div>
                </div>
                <div class="section-card">
                    <h3>第二阶段动态评分</h3>
                    <div class="dialog-grid">
                        <el-form-item label="忙站权重"><el-input-number v-model="profileForm.config.s2BusyWeight" :min="0" :step="0.5" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="堵塞权重"><el-input-number v-model="profileForm.config.s2RunBlockWeight" :min="0" :step="0.5" style="width: 100%;"></el-input-number></el-form-item>
                        <el-form-item label="环线负载权重"><el-input-number v-model="profileForm.config.s2LoopLoadWeight" :min="0" :step="0.5" style="width: 100%;"></el-input-number></el-form-item>
                    </div>
                </div>
            </el-form>
        </div>
        <span slot="footer" class="dialog-footer">
            <el-button @click="profileDialogVisible = false">取消</el-button>
            <el-button type="primary" @click="confirmProfileDialog">确定</el-button>
        </span>
    </el-dialog>
    <el-dialog title="人工规则" :visible.sync="ruleDialogVisible" width="980px" class="dialog-panel" append-to-body :modal="false" :close-on-click-modal="false" :lock-scroll="false" :destroy-on-close="true" top="24px">
        <el-form label-position="top" label-width="120px">
            <div class="dialog-picked-bar">
                <div class="help">{{ ruleDialogPickedHint }}</div>
                <div style="display: flex; gap: 8px; flex-wrap: wrap;">
                    <el-button size="mini" :disabled="!hasPickedStation" @click="applyPickedStation('ruleStart')">带入规则起点</el-button>
                    <el-button size="mini" :disabled="!hasPickedStation" @click="applyPickedStation('ruleEnd')">带入规则终点</el-button>
                    <el-button size="mini" type="primary" plain :disabled="!hasPickedStation" @click="applyPickedStation('mustPass')">加入必经</el-button>
                    <el-button size="mini" type="danger" plain :disabled="!hasPickedStation" @click="applyPickedStation('forbid')">加入禁用</el-button>
                    <el-button size="mini" type="warning" plain :disabled="!hasPickedStation" @click="applyPickedStation('waypoint')">加入途经点</el-button>
                    <el-button size="mini" type="success" plain :disabled="!hasPickedStation" @click="applyPickedStation('preferred')">加入偏好路径</el-button>
                </div>
            </div>
            <div class="section-card">
                <h3>基础信息</h3>
                <div class="dialog-grid">
                    <el-form-item label="规则编码">
                        <el-input v-model.trim="ruleForm.ruleCode" placeholder="如 R-IN-101-221"></el-input>
                    </el-form-item>
                    <el-form-item label="规则名称">
                        <el-input v-model.trim="ruleForm.ruleName" placeholder="规则名称"></el-input>
                    </el-form-item>
                    <el-form-item label="优先级">
                        <el-input-number v-model="ruleForm.priority" :min="1" :max="9999" style="width: 100%;"></el-input-number>
                    </el-form-item>
                    <el-form-item label="状态">
                        <el-switch v-model="ruleForm.status" :active-value="1" :inactive-value="0"></el-switch>
                    </el-form-item>
                    <el-form-item label="起点站点">
                        <el-select v-model="ruleForm.startStationId" filterable clearable placeholder="可留空表示通配">
                            <el-option v-for="item in stationOptions" :key="'rs-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                    </el-form-item>
                    <el-form-item label="终点站点">
                        <el-select v-model="ruleForm.endStationId" filterable clearable placeholder="可留空表示通配">
                            <el-option v-for="item in stationOptions" :key="'re-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                    </el-form-item>
                    <el-form-item label="绑定模板">
                        <el-select v-model="ruleForm.profileCode" filterable clearable placeholder="为空时走默认模板">
                            <el-option v-for="item in profiles" :key="'rp-' + item.profileCode" :label="item.profileName + ' (' + item.profileCode + ')'" :value="item.profileCode"></el-option>
                        </el-select>
                    </el-form-item>
                    <el-form-item label="场景类型">
                        <el-input v-model.trim="ruleForm.sceneType" placeholder="可选,如 inbound / outbound"></el-input>
                    </el-form-item>
                    <el-form-item label="备注" class="span-2">
                        <el-input v-model.trim="ruleForm.memo" placeholder="补充说明规则目的"></el-input>
                    </el-form-item>
                </div>
            </div>
            <div class="section-card">
                <h3>硬约束</h3>
                <div class="section-help">硬约束不会被动态评分推翻。支持必经站点、禁用站点、必经边、禁用边。</div>
                <div class="dialog-grid">
                    <el-form-item label="必经站点" class="span-2">
                        <el-select v-model="ruleForm.hard.mustPassStations" multiple filterable collapse-tags placeholder="选择必经站点">
                            <el-option v-for="item in stationOptions" :key="'must-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                        <div class="sequence-card" v-if="ruleForm.hard.mustPassStations.length">
                            <div class="sequence-row" v-for="(stationId, index) in ruleForm.hard.mustPassStations" :key="'must-row-' + stationId + '-' + index">
                                <span class="sequence-index">{{ index + 1 }}</span>
                                <span class="sequence-label">{{ stationLabel(stationId) }}</span>
                                <div class="sequence-actions">
                                    <el-button size="mini" type="danger" plain icon="el-icon-delete" @click="removeListItem(ruleForm.hard.mustPassStations, index)"></el-button>
                                </div>
                            </div>
                        </div>
                    </el-form-item>
                    <el-form-item label="禁用站点" class="span-2">
                        <el-select v-model="ruleForm.hard.forbidStations" multiple filterable collapse-tags placeholder="选择禁用站点">
                            <el-option v-for="item in stationOptions" :key="'forbid-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                        <div class="sequence-card" v-if="ruleForm.hard.forbidStations.length">
                            <div class="sequence-row" v-for="(stationId, index) in ruleForm.hard.forbidStations" :key="'forbid-row-' + stationId + '-' + index">
                                <span class="sequence-index">{{ index + 1 }}</span>
                                <span class="sequence-label">{{ stationLabel(stationId) }}</span>
                                <div class="sequence-actions">
                                    <el-button size="mini" type="danger" plain icon="el-icon-delete" @click="removeListItem(ruleForm.hard.forbidStations, index)"></el-button>
                                </div>
                            </div>
                        </div>
                    </el-form-item>
                    <el-form-item label="必经边" class="span-2">
                        <el-input type="textarea" :rows="3" v-model="ruleForm.hard.mustPassEdgesText" placeholder="每行一条,如 101->102"></el-input>
                    </el-form-item>
                    <el-form-item label="禁用边" class="span-2">
                        <el-input type="textarea" :rows="3" v-model="ruleForm.hard.forbidEdgesText" placeholder="每行一条,如 215->216"></el-input>
                    </el-form-item>
                </div>
            </div>
            <div class="section-card">
                <h3>关键途经点</h3>
                <div class="section-help">按顺序经过这些站点。若开启严格模式,未命中途经点时会直接判定无路径。</div>
                <div class="dialog-grid">
                    <el-form-item label="途经点序列" class="span-2">
                        <el-select v-model="ruleForm.waypoint.stations" multiple filterable collapse-tags placeholder="按顺序选择途经点">
                            <el-option v-for="item in stationOptions" :key="'wp-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                        <div class="sequence-card" v-if="ruleForm.waypoint.stations.length">
                            <div class="sequence-row" v-for="(stationId, index) in ruleForm.waypoint.stations" :key="'waypoint-row-' + stationId + '-' + index">
                                <span class="sequence-index">{{ index + 1 }}</span>
                                <span class="sequence-label">{{ stationLabel(stationId) }}</span>
                                <div class="sequence-actions">
                                    <el-button size="mini" plain icon="el-icon-top" :disabled="index === 0" @click="moveListItem(ruleForm.waypoint.stations, index, -1)"></el-button>
                                    <el-button size="mini" plain icon="el-icon-bottom" :disabled="index === ruleForm.waypoint.stations.length - 1" @click="moveListItem(ruleForm.waypoint.stations, index, 1)"></el-button>
                                    <el-button size="mini" type="danger" plain icon="el-icon-delete" @click="removeListItem(ruleForm.waypoint.stations, index)"></el-button>
                                </div>
                            </div>
                        </div>
                    </el-form-item>
                    <el-form-item label="严格途经点模式">
                        <el-switch v-model="ruleForm.fallback.strictWaypoint"></el-switch>
                    </el-form-item>
                </div>
            </div>
            <div class="section-card">
                <div class="section-title-row">
                    <h3>软偏好路径</h3>
                    <div class="section-inline-actions">
                        <el-button size="mini" plain @click="importPreviewPathToRule" :disabled="!hasPreviewPath">导入当前预览</el-button>
                        <el-button size="mini" type="primary" plain @click="expandRuleSoftPreferredPath" :loading="softExpandLoading">按关键点展开</el-button>
                        <el-button size="mini" @click="clearSoftPreferredPath" :disabled="!(ruleForm.soft.keyStations.length || ruleForm.soft.preferredPath.length)">清空</el-button>
                    </div>
                </div>
                <div class="section-help">用于表达“人觉得正常应这样走”的推荐线路,算法允许偏离,但偏离会被罚分。</div>
                <div class="dialog-grid">
                    <el-form-item label="关键点序列" class="span-2">
                        <el-select v-model="ruleForm.soft.keyStations" multiple filterable collapse-tags placeholder="只选关键点,系统会自动补全整条偏好路径">
                            <el-option v-for="item in stationOptions" :key="'soft-key-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                        <div class="sequence-card" v-if="ruleForm.soft.keyStations.length">
                            <div class="sequence-row" v-for="(stationId, index) in ruleForm.soft.keyStations" :key="'soft-key-row-' + stationId + '-' + index">
                                <span class="sequence-index">{{ index + 1 }}</span>
                                <span class="sequence-label">{{ stationLabel(stationId) }}</span>
                                <div class="sequence-actions">
                                    <el-button size="mini" plain icon="el-icon-top" :disabled="index === 0" @click="moveListItem(ruleForm.soft.keyStations, index, -1)"></el-button>
                                    <el-button size="mini" plain icon="el-icon-bottom" :disabled="index === ruleForm.soft.keyStations.length - 1" @click="moveListItem(ruleForm.soft.keyStations, index, 1)"></el-button>
                                    <el-button size="mini" type="danger" plain icon="el-icon-delete" @click="removeListItem(ruleForm.soft.keyStations, index)"></el-button>
                                </div>
                            </div>
                        </div>
                    </el-form-item>
                    <el-form-item label="完整偏好路径" class="span-2">
                        <el-select v-model="ruleForm.soft.preferredPath" multiple filterable collapse-tags placeholder="按顺序选择偏好路径">
                            <el-option v-for="item in stationOptions" :key="'pref-' + item.stationId" :label="item.label" :value="item.stationId"></el-option>
                        </el-select>
                        <div class="sequence-card" v-if="ruleForm.soft.preferredPath.length">
                            <div class="sequence-row" v-for="(stationId, index) in ruleForm.soft.preferredPath" :key="'pref-row-' + stationId + '-' + index">
                                <span class="sequence-index">{{ index + 1 }}</span>
                                <span class="sequence-label">{{ stationLabel(stationId) }}</span>
                                <div class="sequence-actions">
                                    <el-button size="mini" plain icon="el-icon-top" :disabled="index === 0" @click="moveListItem(ruleForm.soft.preferredPath, index, -1)"></el-button>
                                    <el-button size="mini" plain icon="el-icon-bottom" :disabled="index === ruleForm.soft.preferredPath.length - 1" @click="moveListItem(ruleForm.soft.preferredPath, index, 1)"></el-button>
                                    <el-button size="mini" type="danger" plain icon="el-icon-delete" @click="removeListItem(ruleForm.soft.preferredPath, index)"></el-button>
                                </div>
                            </div>
                        </div>
                    </el-form-item>
                    <el-form-item label="偏离罚分权重">
                        <el-input-number v-model="ruleForm.soft.deviationWeight" :min="0" :step="0.5" style="width: 100%;"></el-input-number>
                    </el-form-item>
                    <el-form-item label="允许偏离站点数">
                        <el-input-number v-model="ruleForm.soft.maxOffPathCount" :min="0" :max="50" style="width: 100%;"></el-input-number>
                    </el-form-item>
                    <el-form-item label="允许软偏好降级">
                        <el-switch v-model="ruleForm.fallback.allowSoftDegrade"></el-switch>
                    </el-form-item>
                </div>
            </div>
        </el-form>
        <span slot="footer" class="dialog-footer">
            <el-button @click="ruleDialogVisible = false">取消</el-button>
            <el-button type="primary" @click="confirmRuleDialog">确定</el-button>
        </span>
    </el-dialog>
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/stationPathPolicy/stationPathPolicy.js?v=20260316e"></script>
</body>
</html>
tmp/docs/wcs_wms_plan_check.html
New file
@@ -0,0 +1,922 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <meta http-equiv="Content-Style-Type" content="text/css">
  <title></title>
  <meta name="Author" content="python-docx">
  <meta name="LastAuthor" content="Junjie Xie">
  <meta name="Description" content="generated by python-docx">
  <meta name="CreationTime" content="2013-12-25T07:15:00Z">
  <meta name="ModificationTime" content="2013-12-25T07:15:00Z">
  <meta name="Generator" content="Cocoa HTML Writer">
  <meta name="CocoaVersion" content="2685.4">
  <style type="text/css">
    p.p1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 11.0px 'Songti SC'}
    p.p2 {margin: 0.0px 0.0px 0.0px 0.0px; text-align: justify; font: 11.0px 'Songti SC'; min-height: 16.0px}
    p.p3 {margin: 12.0px 0.0px 8.0px 0.0px; text-align: center; font: 20.0px 'Heiti SC Light'}
    p.p4 {margin: 12.0px 0.0px 8.0px 0.0px; text-align: center; font: 20.0px 'Heiti SC Light'; min-height: 21.0px}
    p.p5 {margin: 8.0px 0.0px 4.0px 0.0px; font: 20.0px 'Heiti SC Light'; min-height: 21.0px}
    p.p6 {margin: 8.0px 0.0px 4.0px 0.0px; font: 13.0px 'Songti SC'}
    p.p7 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Times}
    p.p8 {margin: 8.0px 0.0px 6.0px 0.0px; font: 14.0px 'Songti SC'}
    p.p9 {margin: 0.0px 0.0px 0.0px 0.0px; text-align: justify; font: 12.0px 'Songti SC'}
    p.p10 {margin: 0.0px 0.0px 0.0px 0.0px; text-align: center; font: 10.5px 'Songti SC'}
    p.p11 {margin: 0.0px 0.0px 0.0px 0.0px; font: 10.5px 'Songti SC'}
    p.p12 {margin: 0.0px 0.0px 0.0px 0.0px; text-align: justify; font: 10.5px 'Songti SC'; min-height: 15.0px}
    p.p13 {margin: 0.0px 0.0px 2.0px 0.0px; font: 12.0px 'Songti SC'}
    p.p14 {margin: 0.0px 0.0px 2.0px 0.0px; font: 12.0px 'Songti SC'; min-height: 17.0px}
    p.p15 {margin: 0.0px 0.0px 0.0px 0.0px; text-align: justify; font: 10.5px 'Songti SC'}
    p.p16 {margin: 0.0px 0.0px 0.0px 0.0px; text-align: justify; font: 12.0px Times}
    p.p17 {margin: 0.0px 0.0px 0.0px 0.0px; font: 10.5px Times}
    p.p18 {margin: 0.0px 0.0px 0.0px 0.0px; font: 10.5px 'Songti SC'; min-height: 15.0px}
    span.s1 {text-decoration: underline}
    span.s2 {font: 13.0px Times}
    span.s3 {font: 10.5px Times}
    span.s4 {font: 12.0px Times}
    span.s5 {font: 10.5px 'Songti SC'}
    table.t1 {border-collapse: collapse}
    td.td1 {border-style: solid; border-width: 1.0px 1.0px 1.0px 1.0px; border-color: #bfbfbf #bfbfbf #bfbfbf #bfbfbf; padding: 0.0px 5.0px 0.0px 5.0px}
  </style>
</head>
<body>
<p class="p1"><b>提案编号:______(本人不用填写,提交后由研究院统一编号)</b></p>
<p class="p2"><b></b><br></p>
<p class="p2"><b></b><br></p>
<p class="p2"><b></b><br></p>
<p class="p3"><b>集团公司 2026 年度技术创新<br>
研发项目提案书</b></p>
<p class="p4"><b></b><br></p>
<p class="p4"><b></b><br></p>
<p class="p4"><b></b><br></p>
<p class="p4"><b></b><br></p>
<p class="p4"><b></b><br></p>
<p class="p4"><b></b><br></p>
<p class="p4"><b></b><br></p>
<p class="p5"><b></b><br></p>
<p class="p6"><b>项目名称:</b><span class="s1"><b>面向 WCS/WMS 协同的多Agent AI智能体与Prompt自学习关键技术研发</b></span></p>
<p class="p6"><b>提案完成人:</b></p>
<p class="p6"><b>提案参与人</b><span class="s2"><b>:</b></span></p>
<p class="p7"></p>
<p class="p8"><b>一、立项背景与意义</b></p>
<p class="p9"><b>集团公司 2026 年度技术创新研发项目征集明确鼓励围绕人工智能、机器人调度与算法、安全监控、绿色智能仓储物流等方向开展研发,重点支持能够解决现有产品与系统共性问题、支撑业务发展痛点、并具备前瞻性的技术创新项目。本提案与该方向高度一致,聚焦仓储物流场景中 WCS 与 WMS 协同效率、异常处置能力、控制安全性和产品智能化水平提升。</b></p>
<p class="p9"><b>当前公司在 WCS/WMS 项目交付和运维过程中,已积累了大量业务规则、接口经验、异常处理经验和现场调试知识,但这些能力仍主要依赖人工经验、固定规则和分散文档,存在查询分析效率不高、跨系统协同不足、经验难沉淀、AI 控制边界不清晰等共性问题。</b></p>
<p class="p9"><b>本项目拟在现有系统基础上构建可控、可审计、可复用的智能体平台:在业务层实现多智能体协同分析、建议和辅助决策,在执行层以 MCP 为标准化接入协议实现资源读取、工具调用与 Prompt 编排,在治理层引入 PromptOps、数字孪生仿真和全链路审计机制,逐步打通从“只读辅助”到“受控写入”再到“局部自动化”的能力演进路径。</b></p>
<p class="p9"><b>项目的立项意义主要体现在四个方面:一是沉淀形成可复用的 AI 能力底座,提升公司产品竞争力和项目交付能力;二是把专家经验、SOP 和历史日志转化为系统化知识资产,降低人员依赖;三是在安全可控前提下提升计划生成、异常处置、报表生成和运维辅助效率;四是为后续机器人调度、安全监控、绿色智能仓储等场景提供可复制的共性技术资产。</b></p>
<p class="p8"><b>二、项目目标与拟解决的关键问题</b></p>
<p class="p9"><b>本项目拟在 12 个月内完成多Agent AI 智能体平台核心能力研发,并在 WCS/WMS 典型场景完成试点验证,形成“平台底座 + 安全治理 + 场景应用 + 评测体系”的完整能力闭环。</b></p>
<p class="p9"><b>2.1 项目目标</b></p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p10"><b>序号</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>目标项</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>预期指标</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>1</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立多Agent协同底座</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>覆盖查询解释、计划建议、异常诊断、受控操作 4 类核心场景</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>2</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立 Prompt 自学习与治理机制</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立 Prompt 模板,支持版本管理、离线评测、灰度发布和回滚</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>3</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立 MCP 可控集成能力</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>完成 WMS/WCS/SOP/日志等资源与工具的标准化接入,形成统一调用入口</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>4</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立安全控制闭环</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>高风险操作全部经过权限校验、仿真验证、双重确认和审计留痕</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>5</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>完成试点与评测验证</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>形成本地模型与云端模型对比结论,并在典型场景完成试点落地</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p12"><b></b><br></p>
<p class="p9"><b>2.2 拟解决的关键问题</b></p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p10"><b>关键问题</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>现状表现</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>拟解决思路</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>场景理解与路由不统一</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>查询、分析、控制类请求混杂,依赖人工判断</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>通过场景路由 Agent 分类分流,统一识别风险等级与工具边界</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>业务知识分散、更新困难</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>SOP、接口说明、异常经验分散在文档和个人经验中</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建设知识检索 Agent 与知识库,对文档、日志、工单进行结构化沉淀</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>工具调用不稳定、不可控</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>自然语言直接下沉到系统调用存在风险</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>通过结构化 Prompt、Schema 校验与 MCP Tools 实现标准化调用</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>AI 进入控制环节的安全风险高</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>误操作、越权、提示注入、缺少回滚机制</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>引入权限分级、ABAC 策略、数字孪生仿真、审批和命令账本审计</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>Prompt 使用经验难沉淀</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>Prompt 静态维护,缺少效果数据闭环</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立 PromptOps 流程,以日志、反馈与评测驱动持续优化</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>模型选型缺少统一依据</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>本地模型与云端模型在效果、成本、时延上差异大</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立统一评测矩阵,形成模型选型和混合部署策略</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p8"><b>三、初步技术思路或实施方案</b></p>
<p class="p9"><b>3.1 总体技术路线</b></p>
<p class="p9"><b>项目总体采用“多Agent协同 + PromptOps 持续优化 + MCP 可控集成 + 仿真审计安全闭环”的技术路线。系统分为交互与编排层、能力与知识层、集成与执行层、安全治理层四个层次。</b></p>
<p class="p13"><b>(1)交互与编排层:由编排器协调场景路由 Agent、知识检索 Agent、计划分解 Agent、执行 Agent、安全审查 Agent 和 PromptOps Agent,统一接收业务请求并输出可解释结果。</b></p>
<p class="p13"><b>(2)能力与知识层:建设 Prompt 模板库、知识库、评测集、日志反馈库和版本库,实现 Prompt 初始化、复盘、优化和灰度发布。</b></p>
<p class="p13"><b>(3)集成与执行层:通过 MCP Server 对 WMS、WCS、日志系统、工单系统、仿真系统等进行标准化接入,统一封装 Resources、Tools 和 Prompts 能力。</b></p>
<p class="p13"><b>(4)安全治理层:建立 OAuth Scope + ABAC 策略控制、数字孪生仿真、人工审批、操作审计、补偿回滚和异常熔断机制,保证 AI 应用范围可控、风险可追溯。</b></p>
<p class="p9"><b>3.2 核心实施内容</b></p>
<p class="p13"><b>(1)多Agent 协同能力研发:围绕 WCS/WMS 典型业务,研发场景路由、知识检索、计划分解、工具执行、安全审查和复盘优化等核心 Agent,使复杂任务从“单次问答”升级为“分工协作、逐步验证、结果可解释”的工作流。</b></p>
<p class="p13"><b>(2)Prompt 模板体系与 PromptOps 建设:建设场景识别、业务策略、设备控制、异常诊断、报表复盘、安全合规等六类 Prompt 模板族;建立日志采集、样本沉淀、离线评测、候选 Prompt 生成、灰度发布和版本回滚机制,实现 Prompt 的持续优化。</b></p>
<p class="p13"><b>(3)MCP 可控集成研发:对公司现有 WCS/WMS 的关键资源和工具进行封装,优先接入只读类数据,再逐步扩展到 dry-run 写入、工单生成、波次建议、异常处置建议等工具,最终形成统一的 AI 调用接口层。</b></p>
<p class="p13"><b>(4)安全控制机制研发:对于涉及系统写入和控制的场景,强制引入最小权限、审批流、仿真先行、结果审计和补偿机制,避免 AI 直接对生产系统进行无约束操作。</b></p>
<p class="p13"><b>(5)模型评测与部署策略研发:选取本地可部署模型与云端模型进行统一测试,形成适配 WCS/WMS 场景的模型选型策略,并根据时延、成本、风险等级实现混合路由。</b></p>
<p class="p9"><b>3.3 阶段实施计划</b></p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p10"><b>阶段</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>时间</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>重点任务</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>阶段交付</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>第一阶段</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>T0-T3 月</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>需求梳理、知识资产盘点、只读资源接入、多Agent原型搭建</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>原型系统、首批 Prompt 模板、知识库与只读 MCP 接口</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>第二阶段</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>T4-T8 月</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>PromptOps 建设、评测体系建设、dry-run 写入能力、安全审批与审计机制研发</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>Prompt 版本治理平台、评测报告、中风险场景试点</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>第三阶段</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>T9-T12 月</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>局部受控闭环试点、仿真验证、业务指标验证、成果固化与推广准备</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>试点应用、项目总结报告、推广方案、知识产权材料</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p12"><b></b><br></p>
<p class="p9"><b>3.4 首批落地场景</b></p>
<p class="p13"><b>1. 波次/任务生成与下发建议:提升计划效率,先以 dry-run 方式验证。</b></p>
<p class="p13"><b>2. 异常自诊断与应急处置建议:围绕告警、卡箱、停机、拥堵等问题输出处置建议和确认项。</b></p>
<p class="p13"><b>3. 安全联锁与危险操作拦截:对高风险操作增加 AI 二次校验与拦截能力。</b></p>
<p class="p13"><b>4. 工单、日报和复盘报告自动生成:降低人工文书成本,形成经验沉淀。</b></p>
<p class="p13"><b>5. 培训与 SOP 智能问答:服务一线人员和实施人员的快速查询与学习。</b></p>
<p class="p9"><b>3.5 可行性分析</b></p>
<p class="p9"><b>公司现有 WCS/WMS 产品、接口体系和项目经验为本项目提供了良好的落地基础。项目初期以“只读辅助”和“dry-run 建议”为主,不改变现有核心控制逻辑,可在较低风险下快速验证价值;中后期再在审批、仿真和审计机制成熟的前提下推进受控写入和局部自动化。</b></p>
<p class="p8"><b>四、预期成果与价值</b></p>
<p class="p9"><b>4.1 预期成果形式</b></p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p10"><b>成果类别</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>预期成果</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>平台成果</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>形成 1 套面向 WCS/WMS 的多Agent AI 智能体平台原型</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>集成成果</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>形成 WMS/WCS等标准化 MCP 资源或工具接入能力</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>算法与治理成果</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>形成 Prompt 模板、</b><span class="s3"><b>P</b></span><b>romptOps 流程和模型评测体系</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>场景成果</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>完成典型业务场景试点验证,形成可复用实施模板</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>知识产权成果</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>计划申请软件著作权</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>管理成果</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>形成安全策略、审批机制、审计机制和项目推广规范文档</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p12"><b></b><br></p>
<p class="p9"><b>4.2 技术与业务价值</b></p>
<p class="p13"><b>1. 提升产品智能化水平,通过把多Agent、PromptOps 和 MCP 集成到底层产品能力中,形成公司面向智能仓储项目的差异化竞争力。</b></p>
<p class="p13"><span class="s4"><b>2</b></span><b>. 降低经验依赖与培训成本,把专家经验、SOP 和历史处置案例沉淀为可复用知识资产,降低新人培养成本和跨项目复制成本。</b></p>
<p class="p13"><span class="s4"><b>3</b></span><b>. 强化安全可控能力,通过审批、仿真、审计和补偿机制,将 AI 从“不可控黑箱”转变为“边界清晰、过程可追溯”的工程能力。</b></p>
<p class="p13"><span class="s4"><b>4</b></span><b>. 形成集团共性技术资产,项目成果可向机器人调度、安全监控、园区智能运维和绿色仓储等方向复制推广。</b></p>
<p class="p14"><b></b><br></p>
<p class="p9"><b>4.3 风险评估与应对</b></p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p10"><b>风险项</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>风险说明</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>应对措施</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>技术集成风险</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>WCS/WMS 接口差异大、历史系统标准不统一</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>采用分层适配和标准化封装,先接入共性能力再逐步扩展</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>数据质量风险</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>SOP、日志、工单数据存在缺失、过期或噪声</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立数据清洗、版本控制和人工抽检机制</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>安全风险</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>高风险操作存在越权、误调用、提示注入等问题</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>强制审批、仿真、ABAC 策略、审计留痕和回滚补偿</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>模型效果波动风险</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>模型在不同场景下稳定性和成本差异较大</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>建立统一评测集,采用本地模型与云模型混合路由</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>推广应用风险</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>一线人员使用习惯和信任度需要培养</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>通过只读辅助先落地,逐步扩大应用边界,配套培训与复盘</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p8"><b>五、资源需求初步估算</b></p>
<p class="p9"><b>5.1 所需工时天数估算</b></p>
<p class="p15"><b>按项目建议周期 12 个月、月均 22 个工作日测算,1.0 FTE 约折算为 264 人天,以下为项目核心角色所需工时估算。</b></p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p10"><b>角色</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>所需工时(人天)</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>主要职责</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>项目负责人/架构师</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>264</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>总体方案设计、关键路径推进、跨团队协调</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>AI算法与智能体研发</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>396</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>多Agent、PromptOps、评测体系与模型路由研发</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>WCS后端研发</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>264</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>WCS 侧接口封装、控制工具、审计和仿真联动</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>WMS后端研发</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>264</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>WMS 侧资源与工具接入、业务策略联动</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>前端与交互研发</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>132</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>智能体控制台、结果展示与审批交互</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>测试与运维</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>132</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>测试用例、环境部署、监控告警、回归验证</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>业务专家/现场顾问</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>132</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>场景定义、效果评审、试点推进</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>合计</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>1584</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>按 12 个月、22 个工作日/月估算,核心团队协同投入</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p12"><b></b><br></p>
<p class="p9"><b>5.2 方案一:本地部署硬件成本明细</b></p>
<p class="p16">方案一仅统计本地模型部署所需的核心硬件成本,不含网络配套、供电保障、备份存储、基础部署、安全加固及预备费等扩展项,便于在提案中单独说明试点环境的最小投入规模。</p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p10"><b>类别</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>配置建议</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>单价(元)</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>数量</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>小计(元)</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>备注</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>GPU</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>NVIDIA RTX 6000 Ada 48GB</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>53000</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>2</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>106000</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>主推理节点</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>服务器底座</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>4U GPU服务器机箱 + 主板 + 冗余电源</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>28000</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>1</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>28000</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>双 GPU 服务器</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>CPU</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>Intel Xeon 中端双路</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>9000</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>2</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>18000</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>编排、检索、调度</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>内存</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>ECC DDR5 RECC 64GB x 4 = 256GB</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>15599</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>4</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>62396</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>按单条 64GB 价格测算</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>系统盘/数据盘</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>企业级 NVMe 3.84TB</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>2800</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>2</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>5600</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>模型与向量库</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>Mac Studio</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>Apple Mac Studio M3 Ultra 96GB/1TB</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>32999</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>1</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>32999</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>高配本地模型开发与演示机</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>本地客户端试验机</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>AMD Ryzen 9 9950X3D + RTX 5090 + 256GB DDR5 + 2TB SSD</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>50200</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>1</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p17"><span class="s5"><b>5</b></span><b>240</b><span class="s5"><b>0</b></span></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>按 CPU 5300 + GPU 20600 + 内存 19000 + 主板 3000 + 电源 1500 + 2TB SSD </b><span class="s3"><b>3000</b></span><b>估算</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>合计</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p18"><b></b><br></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p18"><b></b><br></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p18"><b></b><br></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p17"><b>305395</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>约 30.</b><span class="s3"><b>5</b></span><b>万元</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p12"><b></b><br></p>
<p class="p9"><b>5.3 方案二:主流云端 API 使用成本测算</b></p>
<p class="p9"><b>方案二为兼顾复杂推理、低时延问答与外部模型能力补充,项目试点期建议预留主流云端大模型 API 预算。以下测算按 1 USD 约合 7.00 CNY 估算,且仅统计标准文本 Token 费用,不含联网搜索、代码执行、向量存储、工具调用等附加费用。</b></p>
<p class="p9"><b>测算口径如下:</b></p>
<p class="p13"><b>- 月请求量:20000 次</b></p>
<p class="p13"><b>- 平均单次输入:8000 Tokens</b></p>
<p class="p13"><b>- 平均单次输出:2000 Tokens</b></p>
<p class="p13"><b>- 月输入总量:160000000 Tokens</b></p>
<p class="p13"><b>- 月输出总量:40000000 Tokens</b></p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p10"><b>厂商</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>模型</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>输入单价(USD/百万Tokens)</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>输出单价(USD/百万Tokens)</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>月费用(USD)</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>月费用(CNY)</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p10"><b>适用建议</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>OpenAI</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>GPT-5 mini</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>0.25</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>2.00</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>120</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>840</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>常规问答、轻量 Agent 编排、文本生成</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>OpenAI</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>GPT-5.4</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>2.50</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>15.00</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>1000</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>7000</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>复杂推理、方案生成、关键任务复核</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>Google</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>Gemini 2.5 Flash</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>0.30</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>2.50</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>148</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>1036</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>高频低时延场景、实时问答、批量处理</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>Google</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>Gemini 2.5 Pro</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>1.25</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>10.00</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>600</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>4200</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>多步骤分析、长上下文理解、复杂研发辅助</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p9"><b>5.4 方案一与方案二优劣势分析</b></p>
<table cellspacing="0" cellpadding="0" class="t1">
  <tbody>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>对比维度</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>方案一:本地部署</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>方案二:云端 API</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>主要优势</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>数据留存在内网,安全边界更清晰;内网调用稳定,适合高敏感或强合规场景;可按业务需要定制部署与推理链路。</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>无需一次性采购硬件,按量付费,初期投入低;可快速接入高性能模型,适合 PoC 与试点;弹性扩容方便,基础设施运维压力较小。</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>主要劣势</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>一次性硬件和部署投入高,建设周期较长;模型升级、推理优化和故障处理依赖内部团队;复杂推理能力受本地算力与模型版本限制。</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>存在外网依赖,需要评估数据出域、脱敏和审计要求;长期高频调用后累计费用可能上升;高敏感控制场景需要更严格的权限与风控措施。</b></p>
      </td>
    </tr>
    <tr>
      <td valign="middle" class="td1">
        <p class="p11"><b>适用阶段</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>更适合试点成熟后、调用规模稳定且对数据本地化和长期可控性要求较高的阶段。</b></p>
      </td>
      <td valign="middle" class="td1">
        <p class="p11"><b>更适合项目初期快速验证业务价值、模型效果和场景边界的阶段。</b></p>
      </td>
    </tr>
  </tbody>
</table>
<p class="p9"><b>5.5 初期建议采用方案二的原因</b></p>
<p class="p15"><b>综合项目当前处于 PoC 与试点验证阶段,建议项目初期优先采用方案二(云端 API)作为主方案。核心原因在于方案二无需一次性投入约 30.5 万元的本地硬件采购成本即可快速启动验证;按上表测算,云端 API 月度成本约为 840 元至 7000 元,即使按较高档模型全年测算约 8.4 万元,初期总体投入仍明显低于方案一。</b></p>
<p class="p15"><b>此外,方案二可直接获得更强的通用模型能力和更快的版本迭代速度,减少服务器采购、部署、驱动适配和模型运维等前置工作,使研发资源集中投入到多Agent 编排、Prompt 自学习和 WCS/WMS 场景打磨中。待试点场景稳定、调用规模放大且数据本地化要求进一步明确后,再评估引入方案一或形成“云端 API 先行验证 + 本地部署按需落地”的混合路线。</b></p>
<p class="p12"><b></b><br></p>
<p class="p9"><b>5.6 周期计划</b></p>
<p class="p9"><b>项目建议周期为 12 个月,按照“PoC 验证 -&gt; Pilot 试点 -&gt; Production 准生产验证”的方式推进。前 3 个月完成基础底座和首批场景验证;第 4 至 8 个月完成安全治理、PromptOps 和中风险场景试点;第 9 至 12 个月完成试点闭环、指标复盘和成果固化,确保项目在 1 年内形成可验收成果。</b></p>
</body>
</html>