#
Junjie
22 小时以前 614c94e0b079b4d51e96bc02add92b04100bd07b
#
7个文件已添加
6个文件已修改
6239 ■■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/BasMapController.java 290 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/BasMapEditorCell.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/BasMapEditorDoc.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/BasMapEditorElement.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/BasMapEditorService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/impl/BasMapEditorServiceImpl.java 920 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MapCanvas.js 34 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/basMap/basMap.js 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/basMap/editor.js 4051 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/basMap/basMap.html 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/basMap/editor.html 814 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/console.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/fakeTrace.html 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/BasMapController.java
@@ -2,28 +2,18 @@
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.core.common.DateUtils;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.domain.BasMapEditorDoc;
import com.zy.asrs.entity.BasMap;
import com.zy.asrs.entity.BasStation;
import com.zy.asrs.entity.DeviceConfig;
import com.zy.asrs.service.BasDevpService;
import com.zy.asrs.service.BasMapEditorService;
import com.zy.asrs.service.BasMapService;
import com.core.annotations.ManagerAuth;
import com.core.common.BaseRes;
import com.core.common.Cools;
import com.core.common.R;
import com.zy.asrs.service.BasStationService;
import com.zy.asrs.service.DeviceConfigService;
import com.zy.asrs.utils.MapExcelUtils;
import com.zy.common.utils.RedisUtil;
import com.zy.common.web.BaseController;
import com.zy.core.enums.SlaveType;
import com.zy.core.model.StationObjModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -38,15 +28,7 @@
    @Autowired
    private BasMapService basMapService;
    @Autowired
    private BasDevpService basDevpService;
    @Autowired
    private DeviceConfigService deviceConfigService;
    @Autowired
    private RedisUtil redisUtil;
    @Autowired
    private MapExcelUtils mapExcelUtils;
    @Autowired
    private BasStationService basStationService;
    private BasMapEditorService basMapEditorService;
    @RequestMapping(value = "/basMap/{id}/auth")
    @ManagerAuth
@@ -167,224 +149,66 @@
    @PostMapping("/basMap/crn/upload")
    public R uploadExcel(@RequestParam("file") MultipartFile file) {
        File tempFile = null;
        try {
            // 保存上传的文件到临时位置
            String filePath = System.getProperty("java.io.tmpdir") + file.getOriginalFilename();
            file.transferTo(new File(filePath));
            HashMap<Integer, List<List<HashMap<String, Object>>>> dataMap = mapExcelUtils.readExcel(filePath);
            HashMap<Integer, List<StationObjModel>> deviceStationMap = new HashMap<>();
            HashMap<Integer, List<StationObjModel>> barcodeStationMap = new HashMap<>();
            HashMap<Integer, List<StationObjModel>> inStationMap = new HashMap<>();
            HashMap<Integer, List<StationObjModel>> outStationMap = new HashMap<>();
            HashMap<Integer, List<StationObjModel>> runBlockReassignStationMap = new HashMap<>();
            HashMap<Integer, List<StationObjModel>> isOutOrderStationMap = new HashMap<>();
            HashMap<Integer, List<StationObjModel>> isLiftTransferStationMap = new HashMap<>();
            for (Map.Entry<Integer, List<List<HashMap<String, Object>>>> entry : dataMap.entrySet()) {
                Integer lev = entry.getKey();
                List<List<HashMap<String, Object>>> dataList = new ArrayList<>();
                List<List<HashMap<String, Object>>> list = entry.getValue();
                for (int i = 0; i < list.size(); i++) {
                    List<HashMap<String, Object>> bayList = list.get(i);
                    List<HashMap<String, Object>> arrayList = new ArrayList<>();
                    for (int j = 0; j < bayList.size(); j++) {
                        HashMap<String, Object> map = bayList.get(j);
                        HashMap<String, Object> nodeData = new HashMap<>();
                        nodeData.put("value", map.get("value"));
                        String bgColor = map.get("bgColor").toString();
                        String nodeType = map.get("nodeType").toString();
                        if (nodeType.equals("shelf")) {
                            //货架
                            nodeData.put("type", "shelf");
                        } else if (nodeType.equals("crn")) {
                            //堆垛机
                            nodeData.put("type", "crn");
                        } else if (nodeType.equals("dualCrn")) {
                            //双工位堆垛机
                            nodeData.put("type", "dualCrn");
                        } else if (nodeType.equals("devp")) {
                            //输送线
                            nodeData.put("type", "devp");
                            JSONObject value = JSON.parseObject(String.valueOf(map.get("value")));
                            Integer deviceNo = value.getInteger("deviceNo");
                            StationObjModel stationObjModel = new StationObjModel();
                            stationObjModel.setDeviceNo(deviceNo);
                            stationObjModel.setStationId(value.getInteger("stationId"));
                            stationObjModel.setStationLev(lev);
                            List<StationObjModel> stationList = deviceStationMap.getOrDefault(deviceNo, new ArrayList<>());
                            stationList.add(stationObjModel);
                            deviceStationMap.put(deviceNo, stationList);
                            Integer isBarcodeStation = value.getInteger("isBarcodeStation");
                            if (isBarcodeStation != null && isBarcodeStation == 1) {
                                StationObjModel barcodeStationModel = new StationObjModel();
                                barcodeStationModel.setDeviceNo(deviceNo);
                                barcodeStationModel.setStationId(value.getInteger("stationId"));
                                barcodeStationModel.setBarcodeIdx(value.getInteger("barcodeIdx"));
                                if (value.getInteger("backStation") != null) {
                                    StationObjModel backStation = new StationObjModel();
                                    barcodeStationModel.setBackStation(backStation);
                                    backStation.setDeviceNo(value.getInteger("backStationDeviceNo"));
                                    backStation.setStationId(value.getInteger("backStation"));
                                }
                                List<StationObjModel> barcodeStationList = barcodeStationMap.getOrDefault(deviceNo, new ArrayList<>());
                                barcodeStationList.add(barcodeStationModel);
                                barcodeStationMap.put(deviceNo, barcodeStationList);
                            }
                            Integer isInStation = value.getInteger("isInStation");
                            if (isInStation != null && isInStation == 1) {
                                StationObjModel inStationModel = new StationObjModel();
                                StationObjModel barcodeStation = new StationObjModel();
                                inStationModel.setDeviceNo(deviceNo);
                                inStationModel.setStationId(value.getInteger("stationId"));
                                inStationModel.setBarcodeStation(barcodeStation);
                                barcodeStation.setDeviceNo(value.getInteger("barcodeStationDeviceNo"));
                                barcodeStation.setStationId(value.getInteger("barcodeStation"));
                                List<StationObjModel> inStationList = inStationMap.getOrDefault(deviceNo, new ArrayList<>());
                                inStationList.add(inStationModel);
                                inStationMap.put(deviceNo, inStationList);
                            }
                            Integer isOutStation = value.getInteger("isOutStation");
                            if (isOutStation != null && isOutStation == 1) {
                                List<StationObjModel> outStationList = outStationMap.getOrDefault(deviceNo, new ArrayList<>());
                                outStationList.add(stationObjModel);
                                outStationMap.put(deviceNo, outStationList);
                            }
                            Integer runBlockReassign = value.getInteger("runBlockReassign");
                            if (runBlockReassign != null && runBlockReassign == 1) {
                                List<StationObjModel> runBlockReassignStationList = runBlockReassignStationMap.getOrDefault(deviceNo, new ArrayList<>());
                                runBlockReassignStationList.add(stationObjModel);
                                runBlockReassignStationMap.put(deviceNo, runBlockReassignStationList);
                            }
                            Integer isOutOrder = value.getInteger("isOutOrder");
                            if (isOutOrder != null && isOutOrder == 1) {
                                List<StationObjModel> isOutOrderStationList = isOutOrderStationMap.getOrDefault(deviceNo, new ArrayList<>());
                                isOutOrderStationList.add(stationObjModel);
                                isOutOrderStationMap.put(deviceNo, isOutOrderStationList);
                            }
                            Integer isLiftTransfer = value.getInteger("isLiftTransfer");
                            if (isLiftTransfer != null && isLiftTransfer == 1) {
                                List<StationObjModel> isLiftTransferStationList = isLiftTransferStationMap.getOrDefault(deviceNo, new ArrayList<>());
                                isLiftTransferStationList.add(stationObjModel);
                                isLiftTransferStationMap.put(deviceNo, isLiftTransferStationList);
                            }
                        } else if (nodeType.equals("rgv")) {
                            //RGV
                            nodeData.put("type", "rgv");
                        } else if (nodeType.equals("none")) {
                            //空白区域
                            nodeData.put("type", "none");
                        } else if (nodeType.equals("merge")) {
                            //合并区域
                            nodeData.put("type", "merge");
                            nodeData.put("mergeType", map.get("mergeType"));
                        }
                        nodeData.put("cellWidth", map.get("cellWidth"));
                        nodeData.put("cellHeight", map.get("cellHeight"));
                        nodeData.put("rowSpan", map.get("rowSpan"));
                        nodeData.put("colSpan", map.get("colSpan"));
                        arrayList.add(nodeData);
                    }
                    dataList.add(arrayList);
                }
                BasMap basMap = basMapService.getOne(new QueryWrapper<BasMap>().eq("lev", lev));
                if (basMap == null) {
                    basMap = new BasMap();
                }
                basMap.setData(JSON.toJSONString(dataList));
                basMap.setOriginData(JSON.toJSONString(dataList));
                basMap.setCreateTime(new Date());
                basMap.setUpdateTime(new Date());
                basMap.setLev(lev);
                basMapService.saveOrUpdate(basMap);
            }
            basStationService.remove(new QueryWrapper<>());
            deviceStationMap.forEach((deviceNo, stationList) -> {
                BasDevp basDevp = basDevpService.getOne(new QueryWrapper<BasDevp>().eq("devp_no", deviceNo));
                if (basDevp == null) {
                    basDevp = new BasDevp();
                    basDevp.setDevpNo(deviceNo);
                    basDevp.setCreateTime(new Date());
                    basDevp.setStatus(1);
                }
                List<StationObjModel> barcodeStationList = barcodeStationMap.get(deviceNo);
                List<StationObjModel> inStationList = inStationMap.get(deviceNo);
                List<StationObjModel> outStationList = outStationMap.get(deviceNo);
                List<StationObjModel> runBlockReassignStationList = runBlockReassignStationMap.get(deviceNo);
                List<StationObjModel> isOutOrderStationList = isOutOrderStationMap.get(deviceNo);
                List<StationObjModel> isLiftTransferStationList = isLiftTransferStationMap.get(deviceNo);
                if (barcodeStationList != null) {
                    basDevp.setBarcodeStationList(JSON.toJSONString(barcodeStationList, SerializerFeature.DisableCircularReferenceDetect));
                }
                if (inStationList != null) {
                    basDevp.setInStationList(JSON.toJSONString(inStationList, SerializerFeature.DisableCircularReferenceDetect));
                }
                if (outStationList != null) {
                    basDevp.setOutStationList(JSON.toJSONString(outStationList, SerializerFeature.DisableCircularReferenceDetect));
                }
                if (runBlockReassignStationList != null) {
                    basDevp.setRunBlockReassignLocStationList(JSON.toJSONString(runBlockReassignStationList, SerializerFeature.DisableCircularReferenceDetect));
                }
                if (isOutOrderStationList != null) {
                    basDevp.setIsOutOrderList(JSON.toJSONString(isOutOrderStationList, SerializerFeature.DisableCircularReferenceDetect));
                }
                if (isLiftTransferStationList != null) {
                    basDevp.setIsLiftTransferList(JSON.toJSONString(isLiftTransferStationList, SerializerFeature.DisableCircularReferenceDetect));
                }
                basDevp.setStationList(JSON.toJSONString(stationList, SerializerFeature.DisableCircularReferenceDetect));
                basDevp.setUpdateTime(new Date());
                basDevpService.saveOrUpdate(basDevp);
                DeviceConfig deviceConfig = deviceConfigService.getOne(new QueryWrapper<DeviceConfig>().eq("device_no", deviceNo).eq("device_type", String.valueOf(SlaveType.Devp)));
                if (deviceConfig != null) {
                    deviceConfig.setFakeInitStatus(JSON.toJSONString(stationList));
                    deviceConfigService.updateById(deviceConfig);
                }
                for (StationObjModel stationObjModel : stationList) {
                    BasStation basStation = new BasStation();
                    basStation.setStationId(stationObjModel.getStationId());
                    basStation.setDeviceNo(stationObjModel.getDeviceNo());
                    basStation.setStationLev(stationObjModel.getStationLev());
                    basStation.setCreateTime(new Date());
                    basStation.setStatus(1);
                    basStationService.save(basStation);
                }
            });
            tempFile = createTempUploadFile(file);
            basMapEditorService.importExcelAndPersist(tempFile.getAbsolutePath());
        } catch (Exception e) {
            e.printStackTrace();
            return R.error(e.getMessage());
        } finally {
            deleteQuietly(tempFile);
        }
        return R.ok();
    }
    @GetMapping("/basMap/editor/{lev}/auth")
    @ManagerAuth
    public R getEditorDoc(@PathVariable("lev") Integer lev) {
        BasMapEditorDoc doc = basMapEditorService.getEditorDoc(lev);
        if (doc == null) {
            return R.error("地图不存在");
        }
        return R.ok().add(doc);
    }
    @PostMapping("/basMap/editor/save/auth")
    @ManagerAuth
    public R saveEditorDoc(@RequestBody BasMapEditorDoc doc) {
        basMapEditorService.saveEditorDoc(doc);
        return R.ok();
    }
    @PostMapping("/basMap/editor/importExcel/auth")
    @ManagerAuth
    public R importExcelToEditor(@RequestParam("file") MultipartFile file) {
        File tempFile = null;
        try {
            tempFile = createTempUploadFile(file);
            return R.ok().add(basMapEditorService.importExcelToEditorDocs(tempFile.getAbsolutePath()));
        } catch (Exception e) {
            e.printStackTrace();
            return R.error(e.getMessage());
        } finally {
            deleteQuietly(tempFile);
        }
    }
    private File createTempUploadFile(MultipartFile file) throws IOException {
        String originalName = file == null ? null : file.getOriginalFilename();
        String suffix = ".xlsx";
        if (!Cools.isEmpty(originalName) && originalName.contains(".")) {
            suffix = originalName.substring(originalName.lastIndexOf('.'));
        }
        File tempFile = File.createTempFile("bas_map_", suffix);
        file.transferTo(tempFile);
        return tempFile;
    }
    private void deleteQuietly(File file) {
        if (file != null && file.exists()) {
            file.delete();
        }
    }
}
src/main/java/com/zy/asrs/domain/BasMapEditorCell.java
New file
@@ -0,0 +1,21 @@
package com.zy.asrs.domain;
import lombok.Data;
import java.io.Serializable;
@Data
public class BasMapEditorCell implements Serializable {
    private static final long serialVersionUID = 1L;
    private String type;
    private String value;
    private Integer rowSpan;
    private Integer colSpan;
    private String mergeType;
}
src/main/java/com/zy/asrs/domain/BasMapEditorDoc.java
New file
@@ -0,0 +1,29 @@
package com.zy.asrs.domain;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class BasMapEditorDoc implements Serializable {
    private static final long serialVersionUID = 1L;
    private Integer lev;
    private String editorMode;
    private Double canvasWidth;
    private Double canvasHeight;
    private List<BasMapEditorElement> elements = new ArrayList<>();
    private List<Integer> rowHeights = new ArrayList<>();
    private List<Integer> colWidths = new ArrayList<>();
    private List<List<BasMapEditorCell>> cells = new ArrayList<>();
}
src/main/java/com/zy/asrs/domain/BasMapEditorElement.java
New file
@@ -0,0 +1,25 @@
package com.zy.asrs.domain;
import lombok.Data;
import java.io.Serializable;
@Data
public class BasMapEditorElement implements Serializable {
    private static final long serialVersionUID = 1L;
    private String id;
    private String type;
    private Double x;
    private Double y;
    private Double width;
    private Double height;
    private String value;
}
src/main/java/com/zy/asrs/service/BasMapEditorService.java
New file
@@ -0,0 +1,17 @@
package com.zy.asrs.service;
import com.zy.asrs.domain.BasMapEditorDoc;
import java.io.IOException;
import java.util.List;
public interface BasMapEditorService {
    BasMapEditorDoc getEditorDoc(Integer lev);
    List<BasMapEditorDoc> importExcelToEditorDocs(String filePath) throws IOException;
    void importExcelAndPersist(String filePath) throws IOException;
    void saveEditorDoc(BasMapEditorDoc doc);
}
src/main/java/com/zy/asrs/service/impl/BasMapEditorServiceImpl.java
New file
@@ -0,0 +1,920 @@
package com.zy.asrs.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.TypeReference;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.common.Cools;
import com.core.exception.CoolException;
import com.zy.asrs.domain.BasMapEditorDoc;
import com.zy.asrs.domain.BasMapEditorElement;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.entity.BasMap;
import com.zy.asrs.entity.BasStation;
import com.zy.asrs.entity.DeviceConfig;
import com.zy.asrs.service.BasDevpService;
import com.zy.asrs.service.BasMapEditorService;
import com.zy.asrs.service.BasMapService;
import com.zy.asrs.service.BasStationService;
import com.zy.asrs.service.DeviceConfigService;
import com.zy.asrs.utils.MapExcelUtils;
import com.zy.common.utils.RedisUtil;
import com.zy.core.enums.RedisKeyType;
import com.zy.core.enums.SlaveType;
import com.zy.core.model.StationObjModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
@Service("basMapEditorService")
public class BasMapEditorServiceImpl implements BasMapEditorService {
    private static final String FREE_EDITOR_MODE = "free-v1";
    private static final Set<String> RUNTIME_TYPES = new HashSet<>(Arrays.asList(
            "shelf", "crn", "dualCrn", "devp", "rgv"
    ));
    private static final int DEFAULT_ROW_HEIGHT = 200;
    private static final int DEFAULT_COL_WIDTH = 1000;
    private static final double DEFAULT_CANVAS_WIDTH = 4200D;
    private static final double DEFAULT_CANVAS_HEIGHT = 5200D;
    private static final int X_SCALE = 40;
    private static final int Y_SCALE = 8;
    @Autowired
    private BasMapService basMapService;
    @Autowired
    private BasDevpService basDevpService;
    @Autowired
    private DeviceConfigService deviceConfigService;
    @Autowired
    private BasStationService basStationService;
    @Autowired
    private MapExcelUtils mapExcelUtils;
    @Autowired
    private RedisUtil redisUtil;
    @Override
    public BasMapEditorDoc getEditorDoc(Integer lev) {
        BasMap basMap = basMapService.getOne(new QueryWrapper<BasMap>().eq("lev", lev));
        if (basMap == null) {
            return null;
        }
        BasMapEditorDoc editorDoc = parseEditorDocJson(lev, basMap.getOriginData());
        if (editorDoc != null) {
            return editorDoc;
        }
        if (Cools.isEmpty(basMap.getData())) {
            return null;
        }
        return toFreeEditorDoc(lev, parseStoredMapData(basMap.getData()));
    }
    @Override
    public List<BasMapEditorDoc> importExcelToEditorDocs(String filePath) throws IOException {
        HashMap<Integer, List<List<HashMap<String, Object>>>> rawDataMap = mapExcelUtils.readExcel(filePath);
        List<Integer> levList = new ArrayList<>(rawDataMap.keySet());
        Collections.sort(levList);
        List<BasMapEditorDoc> result = new ArrayList<>();
        for (Integer lev : levList) {
            List<List<HashMap<String, Object>>> storedData = convertRawExcelData(rawDataMap.get(lev));
            result.add(toFreeEditorDoc(lev, storedData));
        }
        return result;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void importExcelAndPersist(String filePath) throws IOException {
        HashMap<Integer, List<List<HashMap<String, Object>>>> rawDataMap = mapExcelUtils.readExcel(filePath);
        List<Integer> levList = new ArrayList<>(rawDataMap.keySet());
        Collections.sort(levList);
        for (Integer lev : levList) {
            List<List<HashMap<String, Object>>> storedData = convertRawExcelData(rawDataMap.get(lev));
            persistFloorMap(lev, storedData, toFreeEditorDoc(lev, storedData));
        }
        rebuildDeviceAndStationSync();
        clearMapCaches();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void saveEditorDoc(BasMapEditorDoc doc) {
        BasMapEditorDoc normalizedDoc = normalizeFreeDoc(doc);
        List<List<HashMap<String, Object>>> storedData = compileToStoredMapData(normalizedDoc);
        persistFloorMap(normalizedDoc.getLev(), storedData, normalizedDoc);
        rebuildDeviceAndStationSync();
        clearMapCaches();
    }
    private void persistFloorMap(Integer lev,
                                 List<List<HashMap<String, Object>>> storedData,
                                 BasMapEditorDoc editorDoc) {
        if (lev == null || lev <= 0) {
            throw new CoolException("楼层不能为空");
        }
        String dataJson = JSON.toJSONString(storedData);
        String editorJson = JSON.toJSONString(editorDoc);
        BasMap basMap = basMapService.getOne(new QueryWrapper<BasMap>().eq("lev", lev));
        Date now = new Date();
        if (basMap == null) {
            basMap = new BasMap();
            basMap.setCreateTime(now);
        } else {
            basMap.setLastData(basMap.getData());
        }
        basMap.setLev(lev);
        basMap.setData(dataJson);
        basMap.setOriginData(editorJson);
        basMap.setUpdateTime(now);
        basMapService.saveOrUpdate(basMap);
    }
    private BasMapEditorDoc parseEditorDocJson(Integer lev, String json) {
        if (Cools.isEmpty(json)) {
            return null;
        }
        try {
            JSONObject object = JSON.parseObject(json);
            if (object == null || !FREE_EDITOR_MODE.equals(object.getString("editorMode"))) {
                return null;
            }
            BasMapEditorDoc doc = JSON.toJavaObject(object, BasMapEditorDoc.class);
            if (doc == null) {
                return null;
            }
            if (doc.getLev() == null || doc.getLev() <= 0) {
                doc.setLev(lev);
            }
            return normalizeFreeDoc(doc);
        } catch (Exception ignore) {
            return null;
        }
    }
    private BasMapEditorDoc toFreeEditorDoc(Integer lev, List<List<HashMap<String, Object>>> storedData) {
        BasMapEditorDoc doc = new BasMapEditorDoc();
        doc.setLev(lev);
        doc.setEditorMode(FREE_EDITOR_MODE);
        doc.setElements(new ArrayList<>());
        if (storedData == null) {
            doc.setCanvasWidth(DEFAULT_CANVAS_WIDTH);
            doc.setCanvasHeight(DEFAULT_CANVAS_HEIGHT);
            return doc;
        }
        int rowCount = storedData.size();
        int colCount = 0;
        for (List<HashMap<String, Object>> row : storedData) {
            if (row != null && row.size() > colCount) {
                colCount = row.size();
            }
        }
        List<Integer> rowHeights = new ArrayList<>();
        for (int r = 0; r < rowCount; r++) {
            rowHeights.add(extractRowHeight(storedData, r));
        }
        List<Integer> colWidths = new ArrayList<>();
        for (int c = 0; c < colCount; c++) {
            colWidths.add(extractColWidth(storedData, c));
        }
        int[] yOffsets = new int[rowCount];
        int[] xOffsets = new int[colCount];
        int totalHeight = 0;
        for (int r = 0; r < rowCount; r++) {
            yOffsets[r] = totalHeight;
            totalHeight += rowHeights.get(r);
        }
        int totalWidth = 0;
        for (int c = 0; c < colCount; c++) {
            xOffsets[c] = totalWidth;
            totalWidth += colWidths.get(c);
        }
        List<BasMapEditorElement> elements = new ArrayList<>();
        for (int r = 0; r < rowCount; r++) {
            List<HashMap<String, Object>> row = storedData.get(r);
            if (row == null) {
                continue;
            }
            for (int c = 0; c < row.size(); c++) {
                HashMap<String, Object> cell = row.get(c);
                String type = normalizeType(getString(cell, "type", "none"));
                if (!RUNTIME_TYPES.contains(type)) {
                    continue;
                }
                int rowSpan = Math.max(1, toInt(cell == null ? null : cell.get("rowSpan"), 1));
                int colSpan = Math.max(1, toInt(cell == null ? null : cell.get("colSpan"), 1));
                int widthRaw = 0;
                for (int cc = c; cc < Math.min(c + colSpan, colWidths.size()); cc++) {
                    widthRaw += colWidths.get(cc);
                }
                int heightRaw = 0;
                for (int rr = r; rr < Math.min(r + rowSpan, rowHeights.size()); rr++) {
                    heightRaw += rowHeights.get(rr);
                }
                BasMapEditorElement element = new BasMapEditorElement();
                element.setId("el_" + r + "_" + c);
                element.setType(type);
                element.setX(xOffsets[c] / (double) X_SCALE);
                element.setY(yOffsets[r] / (double) Y_SCALE);
                element.setWidth(widthRaw / (double) X_SCALE);
                element.setHeight(heightRaw / (double) Y_SCALE);
                element.setValue(stringifyValue(cell == null ? null : cell.get("value")));
                elements.add(element);
            }
        }
        doc.setElements(elements);
        doc.setCanvasWidth(Math.max(DEFAULT_CANVAS_WIDTH, totalWidth / (double) X_SCALE + 120D));
        doc.setCanvasHeight(Math.max(DEFAULT_CANVAS_HEIGHT, totalHeight / (double) Y_SCALE + 120D));
        return normalizeFreeDoc(doc);
    }
    private BasMapEditorDoc normalizeFreeDoc(BasMapEditorDoc source) {
        if (source == null || source.getLev() == null || source.getLev() <= 0) {
            throw new CoolException("楼层不能为空");
        }
        BasMapEditorDoc doc = new BasMapEditorDoc();
        doc.setLev(source.getLev());
        doc.setEditorMode(FREE_EDITOR_MODE);
        doc.setCanvasWidth(toPositiveCanvasValue(source.getCanvasWidth(), DEFAULT_CANVAS_WIDTH, "画布宽度"));
        doc.setCanvasHeight(toPositiveCanvasValue(source.getCanvasHeight(), DEFAULT_CANVAS_HEIGHT, "画布高度"));
        List<BasMapEditorElement> elements = new ArrayList<>();
        List<BasMapEditorElement> sourceElements = source.getElements();
        if (sourceElements != null) {
            for (int i = 0; i < sourceElements.size(); i++) {
                elements.add(normalizeElement(sourceElements.get(i), i + 1));
            }
        }
        ensureNoRectOverlap(elements);
        doc.setElements(elements);
        return doc;
    }
    private BasMapEditorElement normalizeElement(BasMapEditorElement source, int index) {
        BasMapEditorElement element = new BasMapEditorElement();
        element.setId(Cools.isEmpty(source == null ? null : source.getId()) ? "el_" + index : source.getId().trim());
        element.setType(normalizeType(source == null ? null : source.getType()));
        if (!RUNTIME_TYPES.contains(element.getType())) {
            throw new CoolException("存在不支持的节点类型: " + element.getType());
        }
        double x = toNonNegativeDouble(source == null ? null : source.getX(), "元素 X 坐标");
        double y = toNonNegativeDouble(source == null ? null : source.getY(), "元素 Y 坐标");
        double width = toPositiveCanvasValue(source == null ? null : source.getWidth(), 0D, "元素宽度");
        double height = toPositiveCanvasValue(source == null ? null : source.getHeight(), 0D, "元素高度");
        element.setX(x);
        element.setY(y);
        element.setWidth(width);
        element.setHeight(height);
        element.setValue(stringifyValue(source == null ? null : source.getValue()));
        if ("devp".equals(element.getType())) {
            validateDevpValue(element.getValue(), index);
        }
        return element;
    }
    private void ensureNoRectOverlap(List<BasMapEditorElement> elements) {
        for (int i = 0; i < elements.size(); i++) {
            BasMapEditorElement a = elements.get(i);
            for (int j = i + 1; j < elements.size(); j++) {
                BasMapEditorElement b = elements.get(j);
                if (rectanglesOverlap(a, b)) {
                    throw new CoolException("元素存在重叠: " + a.getId() + " 与 " + b.getId());
                }
            }
        }
    }
    private boolean rectanglesOverlap(BasMapEditorElement a, BasMapEditorElement b) {
        int aLeft = toRawWidth(a.getX());
        int aTop = toRawHeight(a.getY());
        int aRight = toRawWidth(safeDouble(a.getX()) + safeDouble(a.getWidth()));
        int aBottom = toRawHeight(safeDouble(a.getY()) + safeDouble(a.getHeight()));
        int bLeft = toRawWidth(b.getX());
        int bTop = toRawHeight(b.getY());
        int bRight = toRawWidth(safeDouble(b.getX()) + safeDouble(b.getWidth()));
        int bBottom = toRawHeight(safeDouble(b.getY()) + safeDouble(b.getHeight()));
        return aLeft < bRight && aRight > bLeft && aTop < bBottom && aBottom > bTop;
    }
    private List<List<HashMap<String, Object>>> compileToStoredMapData(BasMapEditorDoc doc) {
        List<BasMapEditorElement> elements = doc.getElements() == null ? new ArrayList<BasMapEditorElement>() : doc.getElements();
        if (elements.isEmpty()) {
            return buildEmptyStoredMapData(doc);
        }
        TreeSet<Integer> xBounds = new TreeSet<>();
        TreeSet<Integer> yBounds = new TreeSet<>();
        xBounds.add(0);
        yBounds.add(0);
        List<CompiledRect> rects = new ArrayList<>();
        for (BasMapEditorElement element : elements) {
            CompiledRect rect = toCompiledRect(element);
            rects.add(rect);
            xBounds.add(rect.left);
            xBounds.add(rect.right);
            yBounds.add(rect.top);
            yBounds.add(rect.bottom);
        }
        List<Integer> xList = new ArrayList<>(xBounds);
        List<Integer> yList = new ArrayList<>(yBounds);
        if (xList.size() < 2 || yList.size() < 2) {
            return buildEmptyStoredMapData(doc);
        }
        int rowCount = yList.size() - 1;
        int colCount = xList.size() - 1;
        String[][] occupancy = new String[rowCount][colCount];
        Map<String, Integer> xIndexMap = buildBoundaryIndexMap(xList);
        Map<String, Integer> yIndexMap = buildBoundaryIndexMap(yList);
        List<List<HashMap<String, Object>>> stored = new ArrayList<>();
        for (int r = 0; r < rowCount; r++) {
            List<HashMap<String, Object>> row = new ArrayList<>();
            int cellHeight = yList.get(r + 1) - yList.get(r);
            for (int c = 0; c < colCount; c++) {
                int cellWidth = xList.get(c + 1) - xList.get(c);
                row.add(createStoredCell("none", "", cellWidth, cellHeight, 1, 1, ""));
            }
            stored.add(row);
        }
        for (CompiledRect rect : rects) {
            Integer rowStart = yIndexMap.get(String.valueOf(rect.top));
            Integer rowEndIndex = yIndexMap.get(String.valueOf(rect.bottom));
            Integer colStart = xIndexMap.get(String.valueOf(rect.left));
            Integer colEndIndex = xIndexMap.get(String.valueOf(rect.right));
            if (rowStart == null || rowEndIndex == null || colStart == null || colEndIndex == null) {
                throw new CoolException("地图编译失败: 元素边界无法映射到网格");
            }
            int rowSpan = rowEndIndex - rowStart;
            int colSpan = colEndIndex - colStart;
            if (rowSpan <= 0 || colSpan <= 0) {
                throw new CoolException("地图编译失败: 元素尺寸无效 " + rect.id);
            }
            for (int r = rowStart; r < rowStart + rowSpan; r++) {
                for (int c = colStart; c < colStart + colSpan; c++) {
                    if (occupancy[r][c] != null) {
                        throw new CoolException("地图编译失败: 元素重叠 " + rect.id + " 与 " + occupancy[r][c]);
                    }
                    occupancy[r][c] = rect.id;
                }
            }
            stored.get(rowStart).set(colStart, createStoredCell(
                    rect.type,
                    rect.value,
                    xList.get(colStart + 1) - xList.get(colStart),
                    yList.get(rowStart + 1) - yList.get(rowStart),
                    rowSpan,
                    colSpan,
                    ""
            ));
            for (int r = rowStart; r < rowStart + rowSpan; r++) {
                for (int c = colStart; c < colStart + colSpan; c++) {
                    if (r == rowStart && c == colStart) {
                        continue;
                    }
                    stored.get(r).set(c, createStoredCell(
                            "merge",
                            rect.value,
                            xList.get(c + 1) - xList.get(c),
                            yList.get(r + 1) - yList.get(r),
                            1,
                            1,
                            rect.type
                    ));
                }
            }
        }
        return stored;
    }
    private List<List<HashMap<String, Object>>> buildEmptyStoredMapData(BasMapEditorDoc doc) {
        int widthRaw = Math.max(DEFAULT_COL_WIDTH, toRawWidth(doc == null ? null : doc.getCanvasWidth()));
        int heightRaw = Math.max(DEFAULT_ROW_HEIGHT, toRawHeight(doc == null ? null : doc.getCanvasHeight()));
        List<List<HashMap<String, Object>>> stored = new ArrayList<>();
        List<HashMap<String, Object>> row = new ArrayList<>();
        row.add(createStoredCell("none", "", widthRaw, heightRaw, 1, 1, ""));
        stored.add(row);
        return stored;
    }
    private CompiledRect toCompiledRect(BasMapEditorElement element) {
        CompiledRect rect = new CompiledRect();
        rect.id = element.getId();
        rect.type = element.getType();
        rect.value = normalizeCellValue(element.getType(), element.getValue());
        rect.left = toRawWidth(element.getX());
        rect.top = toRawHeight(element.getY());
        rect.right = toRawWidth(safeDouble(element.getX()) + safeDouble(element.getWidth()));
        rect.bottom = toRawHeight(safeDouble(element.getY()) + safeDouble(element.getHeight()));
        if (rect.right <= rect.left || rect.bottom <= rect.top) {
            throw new CoolException("元素尺寸无效: " + element.getId());
        }
        return rect;
    }
    private Map<String, Integer> buildBoundaryIndexMap(List<Integer> bounds) {
        Map<String, Integer> result = new LinkedHashMap<>();
        for (int i = 0; i < bounds.size(); i++) {
            result.put(String.valueOf(bounds.get(i)), i);
        }
        return result;
    }
    private HashMap<String, Object> createStoredCell(String type,
                                                     String value,
                                                     int cellWidth,
                                                     int cellHeight,
                                                     int rowSpan,
                                                     int colSpan,
                                                     String mergeType) {
        HashMap<String, Object> cell = new HashMap<>();
        cell.put("type", normalizeType(type));
        cell.put("value", stringifyValue(value));
        cell.put("cellWidth", Math.max(1, cellWidth));
        cell.put("cellHeight", Math.max(1, cellHeight));
        cell.put("rowSpan", Math.max(1, rowSpan));
        cell.put("colSpan", Math.max(1, colSpan));
        if ("merge".equals(type)) {
            cell.put("mergeType", normalizeType(mergeType));
        }
        return cell;
    }
    private List<List<HashMap<String, Object>>> parseStoredMapData(String json) {
        if (Cools.isEmpty(json)) {
            return new ArrayList<>();
        }
        List<List<HashMap<String, Object>>> data = JSON.parseObject(json, new TypeReference<List<List<HashMap<String, Object>>>>() {});
        return data == null ? new ArrayList<List<HashMap<String, Object>>>() : data;
    }
    private List<List<HashMap<String, Object>>> convertRawExcelData(List<List<HashMap<String, Object>>> rawData) {
        List<List<HashMap<String, Object>>> result = new ArrayList<>();
        if (rawData == null) {
            return result;
        }
        for (List<HashMap<String, Object>> row : rawData) {
            List<HashMap<String, Object>> rowResult = new ArrayList<>();
            if (row != null) {
                for (HashMap<String, Object> cell : row) {
                    rowResult.add(convertRawExcelCell(cell));
                }
            }
            result.add(rowResult);
        }
        return result;
    }
    private HashMap<String, Object> convertRawExcelCell(HashMap<String, Object> rawCell) {
        HashMap<String, Object> target = new HashMap<>();
        String nodeType = getString(rawCell, "nodeType", "none");
        String normalizedType;
        if ("shelf".equals(nodeType)) {
            normalizedType = "shelf";
        } else if ("crn".equals(nodeType)) {
            normalizedType = "crn";
        } else if ("dualCrn".equals(nodeType) || "dualcrn".equals(nodeType)) {
            normalizedType = "dualCrn";
        } else if ("devp".equals(nodeType)) {
            normalizedType = "devp";
        } else if ("rgv".equals(nodeType)) {
            normalizedType = "rgv";
        } else if ("merge".equals(nodeType)) {
            normalizedType = "merge";
        } else {
            normalizedType = "none";
        }
        target.put("type", normalizedType);
        target.put("value", stringifyValue(rawCell == null ? null : rawCell.get("value")));
        target.put("cellWidth", Math.max(1, toInt(rawCell == null ? null : rawCell.get("cellWidth"), DEFAULT_COL_WIDTH)));
        target.put("cellHeight", Math.max(1, toInt(rawCell == null ? null : rawCell.get("cellHeight"), DEFAULT_ROW_HEIGHT)));
        target.put("rowSpan", Math.max(1, toInt(rawCell == null ? null : rawCell.get("rowSpan"), 1)));
        target.put("colSpan", Math.max(1, toInt(rawCell == null ? null : rawCell.get("colSpan"), 1)));
        if ("merge".equals(normalizedType)) {
            target.put("mergeType", normalizeType(getString(rawCell, "mergeType", "none")));
        }
        return target;
    }
    private int extractRowHeight(List<List<HashMap<String, Object>>> storedData, int rowIndex) {
        List<HashMap<String, Object>> row = storedData.get(rowIndex);
        if (row == null) {
            return DEFAULT_ROW_HEIGHT;
        }
        for (HashMap<String, Object> cell : row) {
            int height = toInt(cell == null ? null : cell.get("cellHeight"), 0);
            if (height > 0) {
                return height;
            }
        }
        return DEFAULT_ROW_HEIGHT;
    }
    private int extractColWidth(List<List<HashMap<String, Object>>> storedData, int colIndex) {
        for (List<HashMap<String, Object>> row : storedData) {
            if (row == null || colIndex >= row.size()) {
                continue;
            }
            HashMap<String, Object> cell = row.get(colIndex);
            int width = toInt(cell == null ? null : cell.get("cellWidth"), 0);
            if (width > 0) {
                return width;
            }
        }
        return DEFAULT_COL_WIDTH;
    }
    private void validateDevpValue(String value, int elementIndex) {
        if (Cools.isEmpty(value)) {
            throw new CoolException("输送线节点缺少配置: 元素#" + elementIndex);
        }
        JSONObject jsonObject;
        try {
            jsonObject = JSON.parseObject(value);
        } catch (Exception ex) {
            throw new CoolException("输送线节点配置不是合法JSON: 元素#" + elementIndex);
        }
        if (jsonObject == null || jsonObject.getInteger("stationId") == null || jsonObject.getInteger("deviceNo") == null) {
            throw new CoolException("输送线节点必须包含stationId和deviceNo: 元素#" + elementIndex);
        }
        Integer isInStation = jsonObject.getInteger("isInStation");
        Integer isBarcodeStation = jsonObject.getInteger("isBarcodeStation");
        Integer barcodeIdx = jsonObject.getInteger("barcodeIdx");
        Integer barcodeStation = jsonObject.getInteger("barcodeStation");
        Integer barcodeStationDeviceNo = jsonObject.getInteger("barcodeStationDeviceNo");
        Integer backStation = jsonObject.getInteger("backStation");
        Integer backStationDeviceNo = jsonObject.getInteger("backStationDeviceNo");
        if (isInStation != null && isInStation == 1) {
            if (!isPositiveInteger(barcodeStation) || !isPositiveInteger(barcodeStationDeviceNo)) {
                throw new CoolException("入站点必须包含barcodeStation和barcodeStationDeviceNo: 元素#" + elementIndex);
            }
        }
        if (isBarcodeStation != null && isBarcodeStation == 1) {
            if (!isPositiveInteger(barcodeIdx) || !isPositiveInteger(backStation) || !isPositiveInteger(backStationDeviceNo)) {
                throw new CoolException("条码站必须包含barcodeIdx、backStation和backStationDeviceNo: 元素#" + elementIndex);
            }
        }
    }
    private boolean isPositiveInteger(Integer value) {
        return value != null && value > 0;
    }
    private double toPositiveCanvasValue(Double rawValue, double defaultValue, String fieldLabel) {
        double value = safeDouble(rawValue);
        if (value <= 0) {
            if (defaultValue > 0) {
                return defaultValue;
            }
            throw new CoolException(fieldLabel + "必须大于0");
        }
        return value;
    }
    private double toNonNegativeDouble(Double rawValue, String fieldLabel) {
        double value = safeDouble(rawValue);
        if (value < 0) {
            throw new CoolException(fieldLabel + "不能小于0");
        }
        return value;
    }
    private int toRawWidth(Double displayValue) {
        return Math.max(0, (int) Math.round(safeDouble(displayValue) * X_SCALE));
    }
    private int toRawHeight(Double displayValue) {
        return Math.max(0, (int) Math.round(safeDouble(displayValue) * Y_SCALE));
    }
    private double safeDouble(Double value) {
        return value == null ? 0D : value;
    }
    private void rebuildDeviceAndStationSync() {
        SyncContext context = new SyncContext();
        List<BasMap> basMapList = basMapService.list(new QueryWrapper<BasMap>().orderByAsc("lev"));
        for (BasMap basMap : basMapList) {
            collectStationsFromMap(context, basMap.getLev(), parseStoredMapData(basMap.getData()));
        }
        basStationService.remove(new QueryWrapper<BasStation>());
        syncBasDevp(context);
        syncBasStation(context);
    }
    private void collectStationsFromMap(SyncContext context, Integer lev, List<List<HashMap<String, Object>>> storedData) {
        if (storedData == null) {
            return;
        }
        for (List<HashMap<String, Object>> row : storedData) {
            if (row == null) {
                continue;
            }
            for (HashMap<String, Object> cell : row) {
                String type = normalizeType(getString(cell, "type", "none"));
                if (!"devp".equals(type)) {
                    continue;
                }
                JSONObject value = parseJsonObject(stringifyValue(cell.get("value")));
                if (value == null) {
                    continue;
                }
                Integer deviceNo = value.getInteger("deviceNo");
                Integer stationId = value.getInteger("stationId");
                if (deviceNo == null || stationId == null) {
                    continue;
                }
                StationObjModel stationObjModel = new StationObjModel();
                stationObjModel.setDeviceNo(deviceNo);
                stationObjModel.setStationId(stationId);
                stationObjModel.setStationLev(lev);
                addStationModel(context.deviceStationMap, context.deviceStationDedup, deviceNo, buildStationKey(deviceNo, stationId, lev), stationObjModel);
                context.stationRegistry.put(stationId, stationObjModel);
                Integer isBarcodeStation = value.getInteger("isBarcodeStation");
                if (isBarcodeStation != null && isBarcodeStation == 1) {
                    StationObjModel barcodeStationModel = new StationObjModel();
                    barcodeStationModel.setDeviceNo(deviceNo);
                    barcodeStationModel.setStationId(stationId);
                    barcodeStationModel.setBarcodeIdx(value.getInteger("barcodeIdx"));
                    if (value.getInteger("backStation") != null) {
                        StationObjModel backStation = new StationObjModel();
                        backStation.setDeviceNo(value.getInteger("backStationDeviceNo"));
                        backStation.setStationId(value.getInteger("backStation"));
                        barcodeStationModel.setBackStation(backStation);
                    }
                    addStationModel(context.barcodeStationMap, context.barcodeStationDedup, deviceNo, buildStationKey(deviceNo, stationId, lev), barcodeStationModel);
                }
                Integer isInStation = value.getInteger("isInStation");
                if (isInStation != null && isInStation == 1) {
                    StationObjModel inStationModel = new StationObjModel();
                    inStationModel.setDeviceNo(deviceNo);
                    inStationModel.setStationId(stationId);
                    StationObjModel barcodeStation = new StationObjModel();
                    barcodeStation.setDeviceNo(value.getInteger("barcodeStationDeviceNo"));
                    barcodeStation.setStationId(value.getInteger("barcodeStation"));
                    inStationModel.setBarcodeStation(barcodeStation);
                    addStationModel(context.inStationMap, context.inStationDedup, deviceNo, buildStationKey(deviceNo, stationId, lev), inStationModel);
                }
                Integer isOutStation = value.getInteger("isOutStation");
                if (isOutStation != null && isOutStation == 1) {
                    addStationModel(context.outStationMap, context.outStationDedup, deviceNo, buildStationKey(deviceNo, stationId, lev), cloneStationModel(stationObjModel));
                }
                Integer runBlockReassign = value.getInteger("runBlockReassign");
                if (runBlockReassign != null && runBlockReassign == 1) {
                    addStationModel(context.runBlockReassignStationMap, context.runBlockReassignDedup, deviceNo, buildStationKey(deviceNo, stationId, lev), cloneStationModel(stationObjModel));
                }
                Integer isOutOrder = value.getInteger("isOutOrder");
                if (isOutOrder != null && isOutOrder == 1) {
                    addStationModel(context.outOrderStationMap, context.outOrderDedup, deviceNo, buildStationKey(deviceNo, stationId, lev), cloneStationModel(stationObjModel));
                }
                Integer isLiftTransfer = value.getInteger("isLiftTransfer");
                if (isLiftTransfer != null && isLiftTransfer == 1) {
                    addStationModel(context.liftTransferStationMap, context.liftTransferDedup, deviceNo, buildStationKey(deviceNo, stationId, lev), cloneStationModel(stationObjModel));
                }
            }
        }
    }
    private void syncBasDevp(SyncContext context) {
        Map<Integer, BasDevp> existingMap = new LinkedHashMap<>();
        List<BasDevp> existingList = basDevpService.list();
        for (BasDevp basDevp : existingList) {
            existingMap.put(basDevp.getDevpNo(), basDevp);
        }
        Set<Integer> allDeviceNos = new LinkedHashSet<>();
        allDeviceNos.addAll(existingMap.keySet());
        allDeviceNos.addAll(context.deviceStationMap.keySet());
        Date now = new Date();
        for (Integer deviceNo : allDeviceNos) {
            BasDevp basDevp = existingMap.get(deviceNo);
            if (basDevp == null) {
                basDevp = new BasDevp();
                basDevp.setDevpNo(deviceNo);
                basDevp.setStatus(1);
                basDevp.setCreateTime(now);
            }
            List<StationObjModel> stationList = context.deviceStationMap.get(deviceNo);
            List<StationObjModel> barcodeStationList = context.barcodeStationMap.get(deviceNo);
            List<StationObjModel> inStationList = context.inStationMap.get(deviceNo);
            List<StationObjModel> outStationList = context.outStationMap.get(deviceNo);
            List<StationObjModel> runBlockReassignStationList = context.runBlockReassignStationMap.get(deviceNo);
            List<StationObjModel> outOrderStationList = context.outOrderStationMap.get(deviceNo);
            List<StationObjModel> liftTransferStationList = context.liftTransferStationMap.get(deviceNo);
            basDevp.setStationList(toJsonOrNull(stationList));
            basDevp.setBarcodeStationList(toJsonOrNull(barcodeStationList));
            basDevp.setInStationList(toJsonOrNull(inStationList));
            basDevp.setOutStationList(toJsonOrNull(outStationList));
            basDevp.setRunBlockReassignLocStationList(toJsonOrNull(runBlockReassignStationList));
            basDevp.setIsOutOrderList(toJsonOrNull(outOrderStationList));
            basDevp.setIsLiftTransferList(toJsonOrNull(liftTransferStationList));
            basDevp.setUpdateTime(now);
            basDevpService.saveOrUpdate(basDevp);
            DeviceConfig deviceConfig = deviceConfigService.getOne(new QueryWrapper<DeviceConfig>()
                    .eq("device_no", deviceNo)
                    .eq("device_type", String.valueOf(SlaveType.Devp)));
            if (deviceConfig != null) {
                deviceConfig.setFakeInitStatus(toJsonOrNull(stationList));
                deviceConfigService.updateById(deviceConfig);
            }
        }
    }
    private void syncBasStation(SyncContext context) {
        Date now = new Date();
        for (StationObjModel stationObjModel : context.stationRegistry.values()) {
            BasStation basStation = new BasStation();
            basStation.setStationId(stationObjModel.getStationId());
            basStation.setDeviceNo(stationObjModel.getDeviceNo());
            basStation.setStationLev(stationObjModel.getStationLev());
            basStation.setCreateTime(now);
            basStation.setStatus(1);
            basStationService.save(basStation);
        }
    }
    private void addStationModel(Map<Integer, List<StationObjModel>> targetMap,
                                 Map<Integer, Set<String>> dedupMap,
                                 Integer deviceNo,
                                 String dedupKey,
                                 StationObjModel stationObjModel) {
        Set<String> set = dedupMap.get(deviceNo);
        if (set == null) {
            set = new LinkedHashSet<>();
            dedupMap.put(deviceNo, set);
        }
        if (!set.add(dedupKey)) {
            return;
        }
        List<StationObjModel> list = targetMap.get(deviceNo);
        if (list == null) {
            list = new ArrayList<>();
            targetMap.put(deviceNo, list);
        }
        list.add(stationObjModel);
    }
    private StationObjModel cloneStationModel(StationObjModel source) {
        StationObjModel clone = new StationObjModel();
        clone.setDeviceNo(source.getDeviceNo());
        clone.setStationId(source.getStationId());
        clone.setStationLev(source.getStationLev());
        clone.setBarcodeIdx(source.getBarcodeIdx());
        clone.setDeviceBay(source.getDeviceBay());
        clone.setDeviceLev(source.getDeviceLev());
        clone.setDeviceRow(source.getDeviceRow());
        clone.setDualCrnExecuteStation(source.getDualCrnExecuteStation());
        clone.setBarcodeStation(source.getBarcodeStation());
        clone.setBackStation(source.getBackStation());
        return clone;
    }
    private String toJsonOrNull(List<StationObjModel> list) {
        if (list == null || list.isEmpty()) {
            return null;
        }
        return JSON.toJSONString(list, SerializerFeature.DisableCircularReferenceDetect);
    }
    private void clearMapCaches() {
        redisUtil.del(RedisKeyType.LOC_MAP_BASE.key);
        redisUtil.del(RedisKeyType.LOC_MAST_MAP_LIST.key);
    }
    private String normalizeType(String type) {
        if (type == null) {
            return "none";
        }
        String value = String.valueOf(type).trim();
        if ("dualcrn".equalsIgnoreCase(value)) {
            return "dualCrn";
        }
        if (Cools.isEmpty(value)) {
            return "none";
        }
        return value;
    }
    private String normalizeCellValue(String type, String value) {
        if ("none".equals(type)) {
            return "";
        }
        return stringifyValue(value);
    }
    private String stringifyValue(Object value) {
        if (value == null) {
            return "";
        }
        if (value instanceof String) {
            return (String) value;
        }
        if (value instanceof JSONObject || value instanceof Map || value instanceof List) {
            return JSON.toJSONString(value);
        }
        return String.valueOf(value);
    }
    private String getString(Map<String, Object> map, String key, String defaultValue) {
        if (map == null || !map.containsKey(key) || map.get(key) == null) {
            return defaultValue;
        }
        String value = stringifyValue(map.get(key));
        return Cools.isEmpty(value) ? defaultValue : value;
    }
    private int toInt(Object value, int defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        if (value instanceof Number) {
            return ((Number) value).intValue();
        }
        try {
            return (int) Math.round(Double.parseDouble(String.valueOf(value)));
        } catch (Exception ignore) {
            return defaultValue;
        }
    }
    private JSONObject parseJsonObject(String json) {
        if (Cools.isEmpty(json)) {
            return null;
        }
        try {
            return JSON.parseObject(json);
        } catch (Exception ignore) {
            return null;
        }
    }
    private String buildStationKey(Integer deviceNo, Integer stationId, Integer lev) {
        return deviceNo + "_" + stationId + "_" + lev;
    }
    private static class CompiledRect {
        private String id;
        private String type;
        private String value;
        private int left;
        private int top;
        private int right;
        private int bottom;
    }
    private static class SyncContext {
        private final Map<Integer, List<StationObjModel>> deviceStationMap = new LinkedHashMap<>();
        private final Map<Integer, List<StationObjModel>> barcodeStationMap = new LinkedHashMap<>();
        private final Map<Integer, List<StationObjModel>> inStationMap = new LinkedHashMap<>();
        private final Map<Integer, List<StationObjModel>> outStationMap = new LinkedHashMap<>();
        private final Map<Integer, List<StationObjModel>> runBlockReassignStationMap = new LinkedHashMap<>();
        private final Map<Integer, List<StationObjModel>> outOrderStationMap = new LinkedHashMap<>();
        private final Map<Integer, List<StationObjModel>> liftTransferStationMap = new LinkedHashMap<>();
        private final Map<Integer, StationObjModel> stationRegistry = new LinkedHashMap<>();
        private final Map<Integer, Set<String>> deviceStationDedup = new LinkedHashMap<>();
        private final Map<Integer, Set<String>> barcodeStationDedup = new LinkedHashMap<>();
        private final Map<Integer, Set<String>> inStationDedup = new LinkedHashMap<>();
        private final Map<Integer, Set<String>> outStationDedup = new LinkedHashMap<>();
        private final Map<Integer, Set<String>> runBlockReassignDedup = new LinkedHashMap<>();
        private final Map<Integer, Set<String>> outOrderDedup = new LinkedHashMap<>();
        private final Map<Integer, Set<String>> liftTransferDedup = new LinkedHashMap<>();
    }
}
src/main/webapp/components/MapCanvas.js
@@ -77,6 +77,8 @@
      },
      pixiShelfMap: new Map(),
      pixiTrackMap: new Map(),
      pixiCrnTextureMap: new Map(),
      pixiRgvTextureMap: new Map(),
      pixiDevpTextureMap: new Map(),
      pixiCrnColorTextureMap: new Map(),
      pixiDevpTextureMap: new Map(),
@@ -787,8 +789,7 @@
      });
      this.crnList.forEach((item) => {
        if (this.graphicsCrn == null) { this.graphicsCrn = this.createCrnTexture(item.width * 0.9, item.height * 0.9); }
        let sprite = new PIXI.Sprite(this.graphicsCrn);
        let sprite = this.createCrnSprite(item.width * 0.9, item.height * 0.9);
        const deviceNo = this.getDeviceNo(item.value);
        const taskNo = this.getTaskNo(item.value);
        const style = new PIXI.TextStyle({ fontFamily: 'Arial', fontSize: 12, fill: '#000000', stroke: '#ffffff', strokeThickness: 1 });
@@ -820,8 +821,7 @@
      });
      
      this.dualCrnList.forEach((item) => {
        if (this.graphicsCrn == null) { this.graphicsCrn = this.createCrnTexture(item.width * 0.9, item.height * 0.9); }
        let sprite = new PIXI.Sprite(this.graphicsCrn);
        let sprite = this.createCrnSprite(item.width * 0.9, item.height * 0.9);
        const deviceNo = this.getDeviceNo(item.value);
        const taskNo = this.getTaskNo(item.value);
        const style = new PIXI.TextStyle({ fontFamily: 'Arial', fontSize: 12, fill: '#000000', stroke: '#ffffff', strokeThickness: 1 });
@@ -853,8 +853,7 @@
      });
      
      this.rgvList.forEach((item) => {
        if (this.graphicsRgv == null) { this.graphicsRgv = this.createRgvTexture(item.width * 0.9, item.height * 0.9); }
        let sprite = new PIXI.Sprite(this.graphicsRgv);
        let sprite = this.createRgvSprite(item.width * 0.9, item.height * 0.9);
        const deviceNo = this.getDeviceNo(item.value);
        const taskNo = this.getTaskNo(item.value);
        const style = new PIXI.TextStyle({ fontFamily: 'Arial', fontSize: 12, fill: '#000000', stroke: '#ffffff', strokeThickness: 1 });
@@ -1227,6 +1226,28 @@
      if (texture == undefined) {
        texture = this.createTrackTexture(width, height, trackMask);
        this.pixiTrackMap.set(idx, texture);
      }
      return new PIXI.Sprite(texture);
    },
    createCrnSprite(width, height) {
      const w = Math.max(1, Math.round(width));
      const h = Math.max(1, Math.round(height));
      const key = w + "-" + h;
      let texture = this.pixiCrnTextureMap.get(key);
      if (texture == undefined) {
        texture = this.createCrnTexture(w, h);
        this.pixiCrnTextureMap.set(key, texture);
      }
      return new PIXI.Sprite(texture);
    },
    createRgvSprite(width, height) {
      const w = Math.max(1, Math.round(width));
      const h = Math.max(1, Math.round(height));
      const key = w + "-" + h;
      let texture = this.pixiRgvTextureMap.get(key);
      if (texture == undefined) {
        texture = this.createRgvTexture(w, h);
        this.pixiRgvTextureMap.set(key, texture);
      }
      return new PIXI.Sprite(texture);
    },
@@ -2799,7 +2820,6 @@
    }
  }
});
src/main/webapp/static/js/basMap/basMap.js
@@ -1170,6 +1170,30 @@
                        }
                    });
                },
                openVisualEditor: function (row) {
                    var lev = row && row.lev ? Number(row.lev) : 0;
                    if (!lev) {
                        this.$message.warning('当前记录缺少楼层');
                        return;
                    }
                    window.open(baseUrl + '/views/basMap/editor.html?lev=' + encodeURIComponent(lev), '_blank');
                },
                openVisualEditorByPrompt: function () {
                    var self = this;
                    self.$prompt('请输入需要打开的楼层', '可视化编辑', {
                        confirmButtonText: '打开',
                        cancelButtonText: '取消',
                        inputPattern: /^\d+$/,
                        inputErrorMessage: '请输入数字层数'
                    }).then(function (payload) {
                        var lev = Number($.trim(payload && payload.value ? payload.value : ''));
                        if (!lev) {
                            self.$message.warning('请输入有效楼层');
                            return;
                        }
                        window.open(baseUrl + '/views/basMap/editor.html?lev=' + encodeURIComponent(lev), '_blank');
                    }).catch(function () {});
                },
                promptInitLocMast: function () {
                    var self = this;
                    self.$prompt('请输入初始化库位层数', '初始化库位', {
src/main/webapp/static/js/basMap/editor.js
New file
Diff too large
src/main/webapp/views/basMap/basMap.html
@@ -423,6 +423,13 @@
                        <el-button
                            size="small"
                            plain
                            icon="el-icon-edit-outline"
                            @click="openVisualEditorByPrompt">
                            可视化编辑
                        </el-button>
                        <el-button
                            size="small"
                            plain
                            icon="el-icon-refresh"
                            :loading="initializingLocMast"
                            @click="promptInitLocMast">
@@ -568,8 +575,9 @@
                                <span v-else>{{ valueOrDash(getTableValue(scope.row, field)) }}</span>
                            </template>
                        </el-table-column>
                        <el-table-column label="操作" width="160" fixed="right" align="center">
                        <el-table-column label="操作" width="220" fixed="right" align="center">
                            <template slot-scope="scope">
                                <el-button type="text" @click="openVisualEditor(scope.row)">可视化编辑</el-button>
                                <el-button type="text" @click="openEditDialog(scope.row)">修改</el-button>
                                <el-button type="text" style="color:#f56c6c;" @click="removeRows([scope.row[primaryKeyField]])">删除</el-button>
                            </template>
src/main/webapp/views/basMap/editor.html
New file
@@ -0,0 +1,814 @@
<!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(1180px 540px at -10% -16%, rgba(24, 113, 181, 0.14), transparent 58%),
                radial-gradient(920px 480px at 110% -12%, rgba(14, 148, 136, 0.12), transparent 56%),
                linear-gradient(180deg, #eef4f9 0%, #f8fbfd 100%);
            --card-bg: rgba(255, 255, 255, 0.94);
            --card-border: rgba(216, 226, 238, 0.96);
            --text-main: #213448;
            --text-sub: #63788e;
            --primary: #2f79d6;
            --accent: #169a82;
            --warn: #f08a3c;
            --danger: #d85a5a;
        }
        [v-cloak] { display: none; }
        html, body {
            margin: 0;
            height: 100%;
            font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
            color: var(--text-main);
            background: var(--page-bg);
            overflow: hidden;
        }
        .editor-shell {
            width: 100%;
            height: 100vh;
            margin: 0 auto;
            padding: 8px;
            box-sizing: border-box;
            display: flex;
            flex-direction: column;
            gap: 0;
        }
        .panel-card {
            border-radius: 24px;
            border: 1px solid var(--card-border);
            background: var(--card-bg);
            box-shadow: 0 16px 32px rgba(39, 62, 92, 0.08);
        }
        .workspace {
            min-height: 0;
            flex: 1 1 auto;
            display: flex;
        }
        .panel-card {
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }
        .panel-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 10px;
            padding: 18px 20px 14px;
            border-bottom: 1px solid rgba(221, 230, 239, 0.94);
        }
        .panel-head h2 {
            margin: 0;
            font-size: 16px;
            font-weight: 700;
        }
        .panel-body {
            padding: 16px 18px 18px;
            display: flex;
            flex-direction: column;
            gap: 14px;
            min-height: 0;
            overflow: auto;
        }
        .tool-section,
        .status-stack,
        .action-list {
            display: flex;
            flex-direction: column;
            gap: 8px;
        }
        .tool-section-label {
            font-size: 12px;
            color: var(--text-sub);
            letter-spacing: 0.08em;
            text-transform: uppercase;
        }
        .tool-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 8px;
        }
        .tool-card-btn {
            appearance: none;
            border: 1px solid rgba(193, 205, 219, 0.9);
            background: rgba(255, 255, 255, 0.96);
            border-radius: 14px;
            padding: 10px 12px;
            text-align: left;
            cursor: pointer;
            transition: all 0.18s ease;
            color: var(--text-main);
            display: flex;
            flex-direction: column;
            gap: 4px;
            min-height: 68px;
        }
        .tool-card-btn.active {
            border-color: rgba(55, 127, 212, 0.45);
            background: rgba(235, 244, 253, 0.98);
            box-shadow: 0 8px 18px rgba(47, 121, 214, 0.14);
        }
        .tool-card-btn strong {
            font-size: 13px;
            font-weight: 700;
        }
        .tool-card-btn span {
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.5;
        }
        .status-card,
        .selection-summary,
        .note-card {
            border-radius: 16px;
            border: 1px solid rgba(218, 227, 236, 0.92);
            background: rgba(248, 251, 254, 0.92);
            padding: 12px 14px;
        }
        .status-card strong,
        .selection-summary strong,
        .note-card strong {
            display: block;
            font-size: 13px;
            margin-bottom: 6px;
        }
        .status-card span,
        .selection-summary span,
        .note-card span {
            display: block;
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.65;
        }
        .selection-summary strong {
            font-size: 14px;
            color: var(--text-main);
        }
        .canvas-toolbar {
            padding: 16px 18px 14px;
            border-bottom: 1px solid rgba(221, 230, 239, 0.94);
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 12px;
            flex-wrap: wrap;
        }
        .canvas-toolbar-main {
            flex: 1 1 420px;
            display: flex;
            flex-direction: column;
            gap: 8px;
        }
        .canvas-toolbar-title {
            display: flex;
            flex-direction: column;
            gap: 4px;
        }
        .canvas-toolbar-title h1 {
            margin: 0;
            font-size: 24px;
            font-weight: 700;
            letter-spacing: 0.3px;
        }
        .canvas-toolbar-title span {
            font-size: 13px;
            line-height: 1.65;
            color: var(--text-sub);
        }
        .canvas-toolbar-meta,
        .canvas-toolbar-actions {
            display: flex;
            align-items: center;
            gap: 8px;
            flex-wrap: wrap;
        }
        .canvas-toolbar-actions {
            justify-content: flex-end;
            flex: 0 1 760px;
        }
        .canvas-toolbar-actions .el-input__inner,
        .canvas-toolbar-actions .el-button {
            border-radius: 10px;
        }
        .canvas-meta {
            font-size: 12px;
            color: var(--text-sub);
        }
        .canvas-card {
            flex: 1 1 auto;
            min-width: 0;
            min-height: 0;
        }
        .canvas-wrap {
            position: relative;
            flex: 1 1 auto;
            min-height: 0;
            background:
                linear-gradient(180deg, rgba(245, 249, 252, 0.95) 0%, rgba(251, 252, 254, 0.98) 100%);
        }
        .canvas-stage {
            position: absolute;
            inset: 0;
            overflow: hidden;
            background: #f6f9fc;
        }
        .canvas-host {
            position: absolute;
            inset: 0;
            background: #f6f9fc;
        }
        .canvas-overlay-layer {
            position: absolute;
            inset: 0;
            pointer-events: none;
            z-index: 5;
        }
        .canvas-loading-mask {
            position: absolute;
            inset: 0;
            z-index: 4;
            display: flex;
            align-items: center;
            justify-content: center;
            background: rgba(246, 249, 252, 0.82);
            backdrop-filter: blur(2px);
        }
        .canvas-loading-card {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 8px;
            min-width: 220px;
            padding: 18px 22px;
            border-radius: 18px;
            background: rgba(255, 255, 255, 0.96);
            border: 1px solid rgba(55, 127, 212, 0.16);
            box-shadow: 0 18px 40px rgba(66, 94, 136, 0.12);
            color: var(--text-main);
        }
        .canvas-loading-card strong {
            font-size: 18px;
        }
        .canvas-loading-card span {
            font-size: 13px;
            color: var(--text-sub);
        }
        .overlay-panel {
            position: absolute;
            top: 14px;
            bottom: 14px;
            width: 300px;
            display: flex;
            flex-direction: column;
            border-radius: 22px;
            border: 1px solid rgba(214, 224, 236, 0.96);
            background: #ffffff;
            box-shadow: 0 8px 18px rgba(31, 55, 82, 0.06);
            overflow: hidden;
            pointer-events: auto;
            contain: layout paint;
        }
        .overlay-left {
            left: 14px;
        }
        .overlay-right {
            right: 14px;
            width: 340px;
        }
        .overlay-panel.collapsed {
            width: 68px;
            bottom: auto;
        }
        .overlay-panel.collapsed .panel-body {
            display: none;
        }
        .overlay-panel.collapsed .panel-head {
            align-items: flex-start;
            padding: 12px 10px;
        }
        .overlay-panel.collapsed .panel-head h2 {
            writing-mode: vertical-rl;
            text-orientation: mixed;
            font-size: 14px;
            line-height: 1;
        }
        .overlay-panel.collapsed .canvas-meta {
            display: none;
        }
        .panel-head-actions {
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .panel-toggle {
            appearance: none;
            border: 1px solid rgba(190, 203, 217, 0.96);
            background: rgba(255, 255, 255, 0.96);
            color: var(--text-main);
            width: 30px;
            height: 30px;
            border-radius: 10px;
            cursor: pointer;
            font-size: 14px;
            font-weight: 700;
            line-height: 1;
        }
        .panel-toggle:hover {
            border-color: rgba(55, 127, 212, 0.4);
            color: var(--primary);
        }
        .prop-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 10px;
        }
        .prop-grid .span-2 {
            grid-column: span 2;
        }
        .field-stack {
            display: flex;
            flex-direction: column;
            gap: 6px;
        }
        .field-label {
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.4;
        }
        .field-required {
            color: #d85b52;
            font-weight: 700;
        }
        .field-help {
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.6;
        }
        .direction-grid {
            display: grid;
            grid-template-columns: repeat(4, minmax(0, 1fr));
            gap: 8px;
        }
        .direction-chip {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            gap: 6px;
            min-height: 34px;
            border: 1px solid rgba(55, 127, 212, 0.18);
            border-radius: 12px;
            background: #fff;
            color: var(--text-main);
            cursor: pointer;
            transition: all 0.16s ease;
        }
        .direction-chip:hover {
            border-color: rgba(55, 127, 212, 0.45);
            color: var(--primary);
        }
        .direction-chip.active {
            border-color: rgba(55, 127, 212, 0.85);
            background: rgba(85, 145, 227, 0.12);
            color: var(--primary);
            box-shadow: inset 0 0 0 1px rgba(85, 145, 227, 0.1);
        }
        .direction-arrow {
            font-size: 16px;
            font-weight: 700;
            line-height: 1;
        }
        .check-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 8px 12px;
        }
        .json-box {
            font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
        }
        .footer-note {
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.75;
        }
        @media (max-width: 1380px) {
            .overlay-left {
                width: 260px;
            }
            .overlay-right {
                width: 300px;
            }
        }
        @media (max-width: 980px) {
            .canvas-card {
                min-height: 0;
            }
            .canvas-wrap {
                min-height: 0;
            }
            .overlay-left,
            .overlay-right {
                width: min(280px, calc(100% - 28px));
            }
        }
    </style>
</head>
<body>
<div id="app" class="editor-shell" v-cloak>
    <section class="workspace">
        <main class="panel-card canvas-panel canvas-card">
            <div class="canvas-toolbar">
                <div class="canvas-toolbar-main">
                    <div class="canvas-toolbar-title">
                        <h1>PixiJS 自由画布地图编辑器</h1>
                        <span>编辑态使用自由画布 JSON,保存时再编译成现有运行地图,所以 `MapCanvas` 和后端算法继续只读 `BasMap.data`。</span>
                    </div>
                    <div class="canvas-toolbar-meta">
                        <span class="canvas-meta">楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span>
                        <span class="canvas-meta">缩放: {{ viewPercent }}%</span>
                        <span class="canvas-meta">模式: {{ toolLabel(activeTool) }}</span>
                        <span class="canvas-meta">选中: {{ selectedIds.length }}</span>
                        <span class="canvas-meta">元素: {{ doc ? doc.elements.length : 0 }}</span>
                        <span class="canvas-meta">画布: {{ Math.round(doc ? doc.canvasWidth : 0) }} x {{ Math.round(doc ? doc.canvasHeight : 0) }}</span>
                        <span class="canvas-meta">渲染倍率: {{ formatNumber(pixiResolution) }}x</span>
                        <span class="canvas-meta">FPS: {{ fpsText }}</span>
                    </div>
                </div>
                <div class="canvas-toolbar-actions">
                    <el-select v-model="floorPickerLev" size="small" placeholder="选择楼层" @change="handleFloorChange" style="width: 120px;">
                        <el-option v-for="lev in levOptions" :key="'lev-' + lev" :label="lev + 'F'" :value="lev"></el-option>
                    </el-select>
                    <el-button size="small" plain @click="openBlankDialog">新建自由画布</el-button>
                    <el-button size="small" plain @click="triggerImportExcel">导入 Excel</el-button>
                    <el-button size="small" plain @click="triggerImportMap">导入地图</el-button>
                    <el-button size="small" plain @click="exportMapPackage">导出地图</el-button>
                    <el-button size="small" plain @click="loadCurrentFloor">重新读取</el-button>
                    <el-button size="small" plain @click="fitContent">适配全图</el-button>
                    <el-button size="small" plain @click="resetView">回到画布</el-button>
                    <el-button size="small" @click="undo" :disabled="undoStack.length === 0">撤销</el-button>
                    <el-button size="small" @click="redo" :disabled="redoStack.length === 0">重做</el-button>
                    <el-button size="small" type="primary" plain :loading="savingAll" :disabled="dirtyDraftCount === 0 || saving" @click="saveAllDocs">保存全部楼层<span v-if="dirtyDraftCount > 0">({{ dirtyDraftCount }})</span></el-button>
                    <el-button size="small" type="primary" :loading="saving" :disabled="savingAll" @click="saveDoc">保存当前楼层</el-button>
                </div>
            </div>
                <div class="canvas-wrap">
                    <div class="canvas-stage" ref="canvasStage">
                        <div class="canvas-host" ref="canvasHost"></div>
                        <div v-if="loadingFloor" class="canvas-loading-mask">
                            <div class="canvas-loading-card">
                                <strong>正在加载 {{ switchingFloorLev || floorPickerLev || currentLev || '--' }}F</strong>
                                <span>画布和缓存正在切换,请稍候。</span>
                            </div>
                        </div>
                    </div>
                <div class="canvas-overlay-layer">
                    <aside class="overlay-panel overlay-left" :class="{ collapsed: toolPanelCollapsed }">
                        <div class="panel-head">
                            <h2>工具面板</h2>
                            <div class="panel-head-actions">
                                <span class="canvas-meta">{{ toolLabel(activeTool) }}</span>
                                <button type="button" class="panel-toggle" @click="toggleToolPanel">{{ toolPanelCollapsed ? '>' : '<' }}</button>
                            </div>
                        </div>
                        <div class="panel-body">
                            <div class="tool-section">
                                <div class="tool-section-label">交互</div>
                                <div class="tool-grid">
                                    <button
                                        v-for="tool in interactionTools"
                                        :key="tool.key"
                                        type="button"
                                        class="tool-card-btn"
                                        :class="{ active: activeTool === tool.key }"
                                        @click="setTool(tool.key)">
                                        <strong>{{ tool.label }}</strong>
                                        <span>{{ tool.desc }}</span>
                                    </button>
                                </div>
                            </div>
                            <div class="tool-section">
                                <div class="tool-section-label">绘制元素</div>
                                <div class="tool-grid">
                                    <button
                                        v-for="tool in drawTools"
                                        :key="tool.key"
                                        type="button"
                                        class="tool-card-btn"
                                        :class="{ active: activeTool === tool.key }"
                                        @click="setTool(tool.key)">
                                        <strong>{{ tool.label }}</strong>
                                        <span>{{ tool.desc }}</span>
                                    </button>
                                </div>
                            </div>
                            <div class="tool-section">
                                <div class="tool-section-label">编辑动作</div>
                                <div class="action-list">
                                    <el-button size="small" plain @click="copySelection" :disabled="selectedIds.length === 0">复制</el-button>
                                    <el-button size="small" plain @click="pasteClipboard" :disabled="clipboard.length === 0">粘贴</el-button>
                                    <el-button size="small" plain @click="duplicateSelection" :disabled="selectedIds.length === 0">复制偏移</el-button>
                                    <el-button size="small" plain @click="fitSelection" :disabled="selectedIds.length === 0">聚焦选中</el-button>
                                    <el-button size="small" type="danger" plain @click="deleteSelection" :disabled="selectedIds.length === 0">删除选中</el-button>
                                </div>
                            </div>
                            <div class="status-stack">
                                <div class="status-card">
                                    <strong>快捷键</strong>
                                    <span>`Delete` 删除,`Ctrl/Cmd + Z` 撤销,`Ctrl/Cmd + Shift + Z` / `Ctrl/Cmd + Y` 重做。</span>
                                    <span>`Ctrl/Cmd + C / V` 复制粘贴,按住空格可临时拖动画布,`Shift + 点击` 可增减单个选中。</span>
                                    <span>`阵列` 工具: 先选中一个货架 / 轨道模板,再拖一条水平或竖直线自动补齐一排;货架会按 `排-列` 规则继续编号。</span>
                                </div>
                                <div class="status-card">
                                    <strong>当前状态</strong>
                                    <span>楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span>
                                    <span>指针: {{ pointerStatus }}</span>
                                    <span v-if="arrayPreviewCount > 0">阵列预览: 将生成 {{ arrayPreviewCount }} 个</span>
                                    <span>未保存: {{ isDirty ? '是' : '否' }}</span>
                                </div>
                                <div class="note-card">
                                    <strong>运行边界</strong>
                                    <span>画布里是自由拖拉拽,但运行侧仍只接受轴对齐矩形元素。保存时会编译回当前运行地图,不支持斜线、旋转和任意多边形。</span>
                                </div>
                            </div>
                        </div>
                    </aside>
                    <aside class="overlay-panel overlay-right" :class="{ collapsed: inspectorPanelCollapsed }">
                        <div class="panel-head">
                            <h2>属性面板</h2>
                            <div class="panel-head-actions">
                                <span class="canvas-meta" v-if="singleSelectedElement">{{ singleSelectedElement.type }}</span>
                                <span class="canvas-meta" v-else>{{ selectedIds.length > 1 ? '多选' : '画布' }}</span>
                                <button type="button" class="panel-toggle" @click="toggleInspectorPanel">{{ inspectorPanelCollapsed ? '<' : '>' }}</button>
                            </div>
                        </div>
                        <div class="panel-body">
                            <div class="selection-summary">
                                <strong v-if="singleSelectedElement">单元素编辑</strong>
                                <strong v-else-if="selectedIds.length > 1">多选编辑</strong>
                                <strong v-else>未选中元素</strong>
                                <span v-if="singleSelectedElement">位置 {{ formatNumber(singleSelectedElement.x) }}, {{ formatNumber(singleSelectedElement.y) }} | 尺寸 {{ formatNumber(singleSelectedElement.width) }} x {{ formatNumber(singleSelectedElement.height) }}</span>
                                <span v-else-if="selectedIds.length > 1">当前已选 {{ selectedIds.length }} 个元素,可整体移动、复制或删除。</span>
                                <span v-else-if="activeTool === 'array'">先选中一个货架 / 轨道元素,再拖一条线生成阵列。</span>
                                <span v-else>选择工具后点击元素,或切换框选工具框出一组元素。</span>
                            </div>
                            <div class="tool-section">
                                <div class="tool-section-label">画布设置</div>
                                <div class="prop-grid">
                                    <el-input v-model.trim="canvasForm.width" size="small" placeholder="画布宽度"></el-input>
                                    <el-input v-model.trim="canvasForm.height" size="small" placeholder="画布高度"></el-input>
                                    <el-button class="span-2" size="small" plain @click="applyCanvasSize">应用画布尺寸</el-button>
                                </div>
                            </div>
                            <template v-if="singleSelectedElement">
                                <div class="tool-section">
                                    <div class="tool-section-label">几何属性</div>
                                    <div class="prop-grid">
                                        <el-input size="small" :value="singleSelectedElement.type" disabled></el-input>
                                        <el-input size="small" :value="singleSelectedElement.id" disabled></el-input>
                                        <el-input v-model.trim="geometryForm.x" size="small" placeholder="X"></el-input>
                                        <el-input v-model.trim="geometryForm.y" size="small" placeholder="Y"></el-input>
                                        <el-input v-model.trim="geometryForm.width" size="small" placeholder="宽度"></el-input>
                                        <el-input v-model.trim="geometryForm.height" size="small" placeholder="高度"></el-input>
                                        <el-button class="span-2" size="small" plain @click="applyGeometry">应用几何</el-button>
                                    </div>
                                </div>
                                <div v-if="singleSelectedElement.type === 'devp'" class="tool-section">
                                    <div class="tool-section-label">输送站点配置</div>
                                    <div class="prop-grid">
                                        <div class="field-stack">
                                            <span class="field-label">站号</span>
                                            <el-input v-model.trim="devpForm.stationId" size="small" placeholder="请输入输送站点站号"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">PLC 编号</span>
                                            <el-input v-model.trim="devpForm.deviceNo" size="small" placeholder="请输入输送站点 PLC 编号"></el-input>
                                        </div>
                                        <div class="field-stack span-2">
                                            <span class="field-label">方向</span>
                                            <div class="direction-grid">
                                                <button
                                                    v-for="item in devpDirectionOptions"
                                                    :key="item.key"
                                                    type="button"
                                                    class="direction-chip"
                                                    :class="{ active: isDevpDirectionActive(item.key) }"
                                                    @click="toggleDevpDirection(item.key)">
                                                    <span class="direction-arrow">{{ item.arrow }}</span>
                                                    <span>{{ item.label }}</span>
                                                </button>
                                            </div>
                                            <div class="field-help">点击箭头切换方向,可同时选择多个方向。</div>
                                        </div>
                                        <div class="field-stack span-2">
                                            <span class="field-label">站点类型</span>
                                            <div class="check-grid">
                                                <el-checkbox v-model="devpForm.isBarcodeStation">条码站</el-checkbox>
                                                <el-checkbox v-model="devpForm.isInStation">入站点</el-checkbox>
                                                <el-checkbox v-model="devpForm.isOutStation">出站点</el-checkbox>
                                                <el-checkbox v-model="devpForm.runBlockReassign">堵塞重分配</el-checkbox>
                                                <el-checkbox v-model="devpForm.isOutOrder">出库排序</el-checkbox>
                                                <el-checkbox v-model="devpForm.isLiftTransfer">顶升移栽</el-checkbox>
                                            </div>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">条码索引<span v-if="devpRequiresBarcodeIndex" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.barcodeIdx" size="small" placeholder="条码站时必填,例如 1"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">条码站站号<span v-if="devpRequiresBarcodeLink" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.barcodeStation" size="small" placeholder="入站点时必填,填写条码站站号"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">条码站 PLC 编号<span v-if="devpRequiresBarcodeLink" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.barcodeStationDeviceNo" size="small" placeholder="入站点时必填,填写条码站 PLC 编号"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">退回站站号<span v-if="devpRequiresBackStation" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.backStation" size="small" placeholder="条码站时必填,填写退回站站号"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">退回站 PLC 编号<span v-if="devpRequiresBackStation" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.backStationDeviceNo" size="small" placeholder="条码站时必填,填写退回站 PLC 编号"></el-input>
                                        </div>
                                        <div class="footer-note span-2">
                                            勾选“入站点”后,必须填写条码站站号和条码站 PLC 编号。
                                            勾选“条码站”后,必须填写条码索引、退回站站号和退回站 PLC 编号。
                                        </div>
                                        <el-button class="span-2" size="small" type="primary" plain @click="applyDevpForm">应用输送线配置</el-button>
                                    </div>
                                </div>
                                <div v-if="singleSelectedDeviceElement" class="tool-section">
                                    <div class="tool-section-label">{{ getDeviceConfigLabel(singleSelectedDeviceElement.type) }}</div>
                                    <div class="prop-grid">
                                        <el-input size="small" :value="getDeviceConfigKeyLabel(singleSelectedDeviceElement.type, deviceForm.valueKey)" disabled></el-input>
                                        <el-input v-model.trim="deviceForm.deviceNo" size="small" placeholder="设备编号"></el-input>
                                        <el-button class="span-2" size="small" type="primary" plain @click="applyDeviceForm">应用设备参数</el-button>
                                    </div>
                                    <div class="footer-note" style="padding-top: 8px;">
                                        这里只改设备编号相关键,原始 JSON 里的其他字段会保留;下面仍可直接查看或手工修改 JSON。
                                    </div>
                                </div>
                                <div class="tool-section">
                                    <div class="tool-section-label">{{ singleSelectedElement.type === 'devp' ? '原始 JSON 预览' : (singleSelectedDeviceElement ? '原始 JSON 预览 / 手工编辑' : '值 / JSON 编辑') }}</div>
                                    <el-input
                                        class="json-box"
                                        type="textarea"
                                        :rows="8"
                                        v-model="valueEditorText"
                                        :readonly="singleSelectedElement.type === 'devp'">
                                    </el-input>
                                    <el-button
                                        v-if="singleSelectedElement.type !== 'devp'"
                                        size="small"
                                        type="primary"
                                        plain
                                        @click="applyRawValue"
                                    >应用值</el-button>
                                </div>
                            </template>
                            <div v-if="selectedShelfElements.length > 0" class="tool-section">
                                <div class="tool-section-label">货架自动填充</div>
                                <div class="prop-grid">
                                    <el-input v-model.trim="shelfFillForm.startValue" size="small" placeholder="起始值,例如 12-1"></el-input>
                                    <el-input size="small" :value="'已选货架 ' + selectedShelfElements.length + ' 个'" disabled></el-input>
                                    <el-select v-model="shelfFillForm.rowStep" size="small" placeholder="排方向">
                                        <el-option label="上到下递减" value="desc"></el-option>
                                        <el-option label="上到下递增" value="asc"></el-option>
                                    </el-select>
                                    <el-select v-model="shelfFillForm.colStep" size="small" placeholder="列方向">
                                        <el-option label="左到右递增" value="asc"></el-option>
                                        <el-option label="左到右递减" value="desc"></el-option>
                                    </el-select>
                                    <el-button class="span-2" size="small" type="primary" plain @click="applyShelfAutoFill">按排列填充货架值</el-button>
                                </div>
                                <div class="footer-note" style="padding-top: 8px;">
                                    会按选中货架的实际空间排列分组填充。默认规则是上到下排号递减、左到右列号递增。
                                </div>
                            </div>
                            <div class="footer-note">
                                编辑器只负责自由画布编辑,运行地图继续走当前 `BasMap.data`。所以这里允许自由拖拉矩形元素,但保存前会校验重叠、尺寸越界和 `devp` 必填字段,防止影响现有显示和算法。
                            </div>
                        </div>
                    </aside>
                </div>
            </div>
        </main>
    </section>
    <el-dialog title="新建自由画布" :visible.sync="blankDialogVisible" width="420px" class="dialog-panel" append-to-body>
        <el-form label-width="90px" size="small">
            <el-form-item label="楼层">
                <el-input v-model.trim="blankForm.lev"></el-input>
            </el-form-item>
            <el-form-item label="宽度">
                <el-input v-model.trim="blankForm.width"></el-input>
            </el-form-item>
            <el-form-item label="高度">
                <el-input v-model.trim="blankForm.height"></el-input>
            </el-form-item>
        </el-form>
        <div slot="footer">
            <el-button @click="blankDialogVisible = false">取消</el-button>
            <el-button type="primary" @click="createBlankMap">创建</el-button>
        </div>
    </el-dialog>
    <input ref="importInput" type="file" style="display:none;" @change="handleImportExcel">
    <input ref="mapImportInput" type="file" accept=".json,application/json" style="display:none;" @change="handleImportMap">
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></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/pixi-legacy.min.js"></script>
<script type="text/javascript" src="../../static/js/basMap/editor.js?v=20260321c"></script>
</body>
</html>
src/main/webapp/views/watch/console.html
@@ -553,7 +553,7 @@
        <script src="../../components/WatchDualCrnCard.js"></script>
        <script src="../../components/DevpCard.js"></script>
        <script src="../../components/WatchRgvCard.js"></script>
        <script src="../../components/MapCanvas.js?v=20260319_fake_trace_overlay1"></script>
        <script src="../../components/MapCanvas.js?v=20260320_crn_size_fix1"></script>
        <script>
            let ws;
            var app = new Vue({
src/main/webapp/views/watch/fakeTrace.html
@@ -517,7 +517,7 @@
    </div>
</div>
<script src="../../components/MapCanvas.js?v=20260319_fake_trace_overlay1"></script>
<script src="../../components/MapCanvas.js?v=20260320_crn_size_fix1"></script>
<script>
    var fakeTraceWs = null;