| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | package com.zy.asrs.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.zy.asrs.entity.BasStationPathProfile; |
| | | |
| | | public interface BasStationPathProfileService extends IService<BasStationPathProfile> { |
| | | } |
| New file |
| | |
| | | package com.zy.asrs.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.zy.asrs.entity.BasStationPathRule; |
| | | |
| | | public interface BasStationPathRuleService extends IService<BasStationPathRule> { |
| | | } |
| New file |
| | |
| | | package com.zy.asrs.service; |
| | | |
| | | import com.zy.asrs.domain.path.StationPathResolvedPolicy; |
| | | |
| | | public interface StationPathPolicyService { |
| | | |
| | | StationPathResolvedPolicy resolvePolicy(Integer startStationId, Integer endStationId); |
| | | |
| | | void evictCache(); |
| | | } |
| New file |
| | |
| | | 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 { |
| | | } |
| New file |
| | |
| | | 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 { |
| | | } |
| New file |
| | |
| | | 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<>(); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | |
| | | @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); |
| | |
| | | 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<>(); |
| | |
| | | |
| | | 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); |
| | | |
| | | //去重 |
| | |
| | | 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<>(); |
| New file |
| | |
| | | -- 将 输送路径策略管理 菜单挂载到:基础资料(优先)或开发专用 |
| | | -- 说明:执行本脚本后,请在“角色授权”里给对应角色勾选新菜单和“查看”权限。 |
| | | |
| | | 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; |
| New file |
| | |
| | | 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' |
| | | ); |
| New file |
| | |
| | | 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() === '' |
| | | } |
| | | } |
| | | }) |
| New file |
| | |
| | | <!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> |
| New file |
| | |
| | | <!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 验证 -> Pilot 试点 -> Production 准生产验证”的方式推进。前 3 个月完成基础底座和首批场景验证;第 4 至 8 个月完成安全治理、PromptOps 和中风险场景试点;第 9 至 12 个月完成试点闭环、指标复盘和成果固化,确保项目在 1 年内形成可验收成果。</b></p> |
| | | </body> |
| | | </html> |