From 614c94e0b079b4d51e96bc02add92b04100bd07b Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期六, 21 三月 2026 09:12:23 +0800
Subject: [PATCH] #

---
 src/main/webapp/components/MapCanvas.js                             |   34 
 src/main/webapp/views/watch/console.html                            |    2 
 src/main/webapp/views/watch/fakeTrace.html                          |    2 
 src/main/java/com/zy/asrs/domain/BasMapEditorElement.java           |   25 
 src/main/java/com/zy/asrs/controller/BasMapController.java          |  290 --
 src/main/webapp/static/js/basMap/basMap.js                          |   24 
 src/main/java/com/zy/asrs/service/impl/BasMapEditorServiceImpl.java |  920 ++++++++
 src/main/webapp/static/js/basMap/editor.js                          | 4051 ++++++++++++++++++++++++++++++++++++++
 src/main/java/com/zy/asrs/domain/BasMapEditorCell.java              |   21 
 src/main/webapp/views/basMap/basMap.html                            |   10 
 src/main/java/com/zy/asrs/service/BasMapEditorService.java          |   17 
 src/main/java/com/zy/asrs/domain/BasMapEditorDoc.java               |   29 
 src/main/webapp/views/basMap/editor.html                            |  814 +++++++
 13 files changed, 5,996 insertions(+), 243 deletions(-)

diff --git a/src/main/java/com/zy/asrs/controller/BasMapController.java b/src/main/java/com/zy/asrs/controller/BasMapController.java
index 1d8b4b2..a26a4b3 100644
--- a/src/main/java/com/zy/asrs/controller/BasMapController.java
+++ b/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();
+        }
+    }
+
 }
diff --git a/src/main/java/com/zy/asrs/domain/BasMapEditorCell.java b/src/main/java/com/zy/asrs/domain/BasMapEditorCell.java
new file mode 100644
index 0000000..7af5df1
--- /dev/null
+++ b/src/main/java/com/zy/asrs/domain/BasMapEditorCell.java
@@ -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;
+}
diff --git a/src/main/java/com/zy/asrs/domain/BasMapEditorDoc.java b/src/main/java/com/zy/asrs/domain/BasMapEditorDoc.java
new file mode 100644
index 0000000..6a80a67
--- /dev/null
+++ b/src/main/java/com/zy/asrs/domain/BasMapEditorDoc.java
@@ -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<>();
+}
diff --git a/src/main/java/com/zy/asrs/domain/BasMapEditorElement.java b/src/main/java/com/zy/asrs/domain/BasMapEditorElement.java
new file mode 100644
index 0000000..a2c25fa
--- /dev/null
+++ b/src/main/java/com/zy/asrs/domain/BasMapEditorElement.java
@@ -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;
+}
diff --git a/src/main/java/com/zy/asrs/service/BasMapEditorService.java b/src/main/java/com/zy/asrs/service/BasMapEditorService.java
new file mode 100644
index 0000000..e98b4fc
--- /dev/null
+++ b/src/main/java/com/zy/asrs/service/BasMapEditorService.java
@@ -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);
+}
diff --git a/src/main/java/com/zy/asrs/service/impl/BasMapEditorServiceImpl.java b/src/main/java/com/zy/asrs/service/impl/BasMapEditorServiceImpl.java
new file mode 100644
index 0000000..fe0f13a
--- /dev/null
+++ b/src/main/java/com/zy/asrs/service/impl/BasMapEditorServiceImpl.java
@@ -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鍜宒eviceNo: 鍏冪礌#" + 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("鍏ョ珯鐐瑰繀椤诲寘鍚玝arcodeStation鍜宐arcodeStationDeviceNo: 鍏冪礌#" + elementIndex);
+            }
+        }
+        if (isBarcodeStation != null && isBarcodeStation == 1) {
+            if (!isPositiveInteger(barcodeIdx) || !isPositiveInteger(backStation) || !isPositiveInteger(backStationDeviceNo)) {
+                throw new CoolException("鏉$爜绔欏繀椤诲寘鍚玝arcodeIdx銆乥ackStation鍜宐ackStationDeviceNo: 鍏冪礌#" + 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<>();
+    }
+}
diff --git a/src/main/webapp/components/MapCanvas.js b/src/main/webapp/components/MapCanvas.js
index 4c2292e..a4c0a7d 100644
--- a/src/main/webapp/components/MapCanvas.js
+++ b/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 @@
     }
   }
 });
-
 
 
 
diff --git a/src/main/webapp/static/js/basMap/basMap.js b/src/main/webapp/static/js/basMap/basMap.js
index bf30ac2..6cada5b 100644
--- a/src/main/webapp/static/js/basMap/basMap.js
+++ b/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('璇疯緭鍏ュ垵濮嬪寲搴撲綅灞傛暟', '鍒濆鍖栧簱浣�', {
diff --git a/src/main/webapp/static/js/basMap/editor.js b/src/main/webapp/static/js/basMap/editor.js
new file mode 100644
index 0000000..3d28b60
--- /dev/null
+++ b/src/main/webapp/static/js/basMap/editor.js
@@ -0,0 +1,4051 @@
+(function () {
+    var FREE_EDITOR_MODE = 'free-v1';
+    var MAP_TRANSFER_FORMAT = 'bas-map-editor-transfer-v2';
+    var HISTORY_LIMIT = 60;
+    var DEFAULT_CANVAS_WIDTH = 6400;
+    var DEFAULT_CANVAS_HEIGHT = 3600;
+    var MIN_ELEMENT_SIZE = 24;
+    var HANDLE_SCREEN_SIZE = 10;
+    var DRAG_START_THRESHOLD = 5;
+    var EDGE_SNAP_SCREEN_TOLERANCE = 8;
+    var COORD_EPSILON = 0.01;
+    var DEFERRED_STATIC_REBUILD_DELAY = 120;
+    var PAN_LABEL_REFRESH_DELAY = 160;
+    var ZOOM_REFRESH_DELAY = 220;
+    var POINTER_STATUS_UPDATE_INTERVAL = 48;
+    var SPATIAL_BUCKET_SIZE = 240;
+    var STATIC_VIEW_PADDING = 120;
+    var MIN_LABEL_SCALE = 0.17;
+    var ABS_MIN_LABEL_SCREEN_WIDTH = 26;
+    var ABS_MIN_LABEL_SCREEN_HEIGHT = 14;
+    var STATIC_SPRITE_SCALE_THRESHOLD = 0.85;
+    var STATIC_SIMPLIFY_SCALE_THRESHOLD = 0.22;
+    var DENSE_SIMPLIFY_SCALE_THRESHOLD = 0.8;
+    var DENSE_SIMPLIFY_ELEMENT_THRESHOLD = 1200;
+    var DENSE_LABEL_HIDE_SCALE_THRESHOLD = 1.05;
+    var DENSE_LABEL_HIDE_ELEMENT_THRESHOLD = 1200;
+    var STATIC_SPRITE_POOL_SLACK = 96;
+    var MIN_LABEL_COUNT = 180;
+    var MAX_LABEL_COUNT = 360;
+    var DRAW_TYPES = ['shelf', 'devp', 'crn', 'dualCrn', 'rgv'];
+    var ARRAY_TEMPLATE_TYPES = ['shelf', 'crn', 'dualCrn', 'rgv'];
+    var DEVICE_CONFIG_TYPES = ['crn', 'dualCrn', 'rgv'];
+    var DEVP_DIRECTION_OPTIONS = [
+        { key: 'top', label: '涓�', arrow: '鈫�' },
+        { key: 'right', label: '鍙�', arrow: '鈫�' },
+        { key: 'bottom', label: '涓�', arrow: '鈫�' },
+        { key: 'left', label: '宸�', arrow: '鈫�' }
+    ];
+    var idSeed = Date.now();
+
+    var TYPE_META = {
+        shelf: { label: '璐ф灦', shortLabel: 'SHELF', fill: 0x7d96bf, border: 0x4f6486 },
+        devp: { label: '杈撻�佺嚎', shortLabel: 'DEVP', fill: 0xf0b06f, border: 0xa45f21 },
+        crn: { label: '鍫嗗灈鏈鸿建閬�', shortLabel: 'CRN', fill: 0x68bfd0, border: 0x1d6e81 },
+        dualCrn: { label: '鍙屽伐浣嶈建閬�', shortLabel: 'DCRN', fill: 0x54c1a4, border: 0x0f7b62 },
+        rgv: { label: 'RGV杞ㄩ亾', shortLabel: 'RGV', fill: 0xc691e9, border: 0x744b98 }
+    };
+
+    function nextId() {
+        idSeed += 1;
+        return 'el_' + idSeed;
+    }
+
+    function deepClone(obj) {
+        return JSON.parse(JSON.stringify(obj == null ? null : obj));
+    }
+
+    function padNumber(value) {
+        return value < 10 ? ('0' + value) : String(value);
+    }
+
+    function authHeaders() {
+        return {
+            token: localStorage.getItem('token')
+        };
+    }
+
+    function getQueryParam(name) {
+        var search = window.location.search || '';
+        if (!search) {
+            return '';
+        }
+        var target = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+        var match = search.match(new RegExp('(?:[?&])' + target + '=([^&]*)'));
+        return match ? decodeURIComponent(match[1]) : '';
+    }
+
+    function toNumber(value, defaultValue) {
+        if (value === null || value === undefined || value === '') {
+            return defaultValue;
+        }
+        var parsed = Number(value);
+        return isFinite(parsed) ? parsed : defaultValue;
+    }
+
+    function toInt(value, defaultValue) {
+        return Math.round(toNumber(value, defaultValue));
+    }
+
+    function clamp(value, min, max) {
+        return Math.max(min, Math.min(max, value));
+    }
+
+    function roundCoord(value) {
+        return Math.round(value * 1000) / 1000;
+    }
+
+    function normalizeValue(value) {
+        if (value === null || value === undefined) {
+            return '';
+        }
+        return typeof value === 'string' ? value : JSON.stringify(value);
+    }
+
+    function parseShelfLocationValue(value) {
+        var text = normalizeValue(value).trim();
+        var matched = text.match(/^(-?\d+)\s*-\s*(-?\d+)$/);
+        if (!matched) {
+            return null;
+        }
+        return {
+            row: toInt(matched[1], 0),
+            col: toInt(matched[2], 0)
+        };
+    }
+
+    function formatShelfLocationValue(row, col) {
+        return String(toInt(row, 0)) + '-' + String(toInt(col, 0));
+    }
+
+    function safeParseJson(text) {
+        if (!text || typeof text !== 'string') {
+            return null;
+        }
+        try {
+            return JSON.parse(text);
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function boolFlag(value) {
+        return value === true || value === 1 || value === '1';
+    }
+
+    function normalizeDirectionList(direction) {
+        var list = Array.isArray(direction) ? direction : String(direction || '').split(/[,\s|/]+/);
+        var result = [];
+        var seen = {};
+        for (var i = 0; i < list.length; i++) {
+            var item = String(list[i] || '').trim().toLowerCase();
+            if (!item || seen[item]) {
+                continue;
+            }
+            seen[item] = true;
+            result.push(item);
+        }
+        return result;
+    }
+
+    function directionTokenToArrow(token) {
+        if (token === 'top' || token === 'up' || token === 'north' || token === 'n') {
+            return '鈫�';
+        }
+        if (token === 'right' || token === 'east' || token === 'e') {
+            return '鈫�';
+        }
+        if (token === 'bottom' || token === 'down' || token === 'south' || token === 's') {
+            return '鈫�';
+        }
+        if (token === 'left' || token === 'west' || token === 'w') {
+            return '鈫�';
+        }
+        return '';
+    }
+
+    function formatDirectionArrows(direction) {
+        var list = normalizeDirectionList(direction);
+        var arrows = [];
+        for (var i = 0; i < list.length; i++) {
+            var arrow = directionTokenToArrow(list[i]);
+            if (arrow) {
+                arrows.push(arrow);
+            }
+        }
+        return arrows.join('');
+    }
+
+    function isDeviceConfigType(type) {
+        return DEVICE_CONFIG_TYPES.indexOf(type) >= 0;
+    }
+
+    function pickDeviceValueKey(type, json) {
+        if (json && json.deviceNo != null) {
+            return 'deviceNo';
+        }
+        if ((type === 'crn' || type === 'dualCrn') && json && json.crnNo != null) {
+            return 'crnNo';
+        }
+        if (type === 'rgv' && json && json.rgvNo != null) {
+            return 'rgvNo';
+        }
+        return 'deviceNo';
+    }
+
+    function isInputLike(target) {
+        if (!target || !target.tagName) {
+            return false;
+        }
+        var tag = String(target.tagName || '').toLowerCase();
+        return tag === 'input' || tag === 'textarea' || tag === 'select' || !!target.isContentEditable;
+    }
+
+    function rectsOverlap(a, b) {
+        return a.x < b.x + b.width - COORD_EPSILON && a.x + a.width > b.x + COORD_EPSILON
+            && a.y < b.y + b.height - COORD_EPSILON && a.y + a.height > b.y + COORD_EPSILON;
+    }
+
+    function rectIntersects(a, b) {
+        return a.x <= b.x + b.width && a.x + a.width >= b.x
+            && a.y <= b.y + b.height && a.y + a.height >= b.y;
+    }
+
+    function isRectWithinCanvas(rect, canvasWidth, canvasHeight) {
+        return rect.x >= -COORD_EPSILON && rect.y >= -COORD_EPSILON
+            && rect.x + rect.width <= canvasWidth + COORD_EPSILON
+            && rect.y + rect.height <= canvasHeight + COORD_EPSILON;
+    }
+
+    function findDocOverlapId(doc) {
+        if (!doc || !doc.elements || !doc.elements.length) {
+            return '';
+        }
+        var buckets = {};
+        var elements = doc.elements;
+        for (var i = 0; i < elements.length; i++) {
+            var element = elements[i];
+            var minX = Math.floor(element.x / SPATIAL_BUCKET_SIZE);
+            var maxX = Math.floor((element.x + element.width) / SPATIAL_BUCKET_SIZE);
+            var minY = Math.floor(element.y / SPATIAL_BUCKET_SIZE);
+            var maxY = Math.floor((element.y + element.height) / SPATIAL_BUCKET_SIZE);
+            for (var bx = minX; bx <= maxX; bx++) {
+                for (var by = minY; by <= maxY; by++) {
+                    var key = bucketKey(bx, by);
+                    var bucket = buckets[key];
+                    if (!bucket || !bucket.length) {
+                        continue;
+                    }
+                    for (var j = 0; j < bucket.length; j++) {
+                        if (rectsOverlap(element, bucket[j])) {
+                            return element.id || ('el_' + i);
+                        }
+                    }
+                }
+            }
+            for (bx = minX; bx <= maxX; bx++) {
+                for (by = minY; by <= maxY; by++) {
+                    key = bucketKey(bx, by);
+                    if (!buckets[key]) {
+                        buckets[key] = [];
+                    }
+                    buckets[key].push(element);
+                }
+            }
+        }
+        return '';
+    }
+
+    function buildRectFromPoints(a, b) {
+        var left = Math.min(a.x, b.x);
+        var top = Math.min(a.y, b.y);
+        var right = Math.max(a.x, b.x);
+        var bottom = Math.max(a.y, b.y);
+        return {
+            x: roundCoord(left),
+            y: roundCoord(top),
+            width: roundCoord(right - left),
+            height: roundCoord(bottom - top)
+        };
+    }
+
+    function getTypeMeta(type) {
+        return TYPE_META[type] || TYPE_META.shelf;
+    }
+
+    function rangesNearOrOverlap(a1, a2, b1, b2, tolerance) {
+        return a1 <= b2 + tolerance && a2 >= b1 - tolerance;
+    }
+
+    function bucketKey(x, y) {
+        return x + ':' + y;
+    }
+
+    function getPreferredResolution() {
+        return Math.min(window.devicePixelRatio || 1, 1.25);
+    }
+
+    new Vue({
+        el: '#app',
+        data: function () {
+            return {
+                remoteLevOptions: [],
+                levOptions: [],
+                currentLev: null,
+                floorPickerLev: null,
+                draftDocs: {},
+                doc: null,
+                activeTool: 'select',
+                toolPanelCollapsed: false,
+                inspectorPanelCollapsed: false,
+                interactionTools: [
+                    { key: 'select', label: '閫夋嫨 / 绉诲姩', desc: '鐐瑰嚮鍏冪礌閫夋嫨锛屾嫋鎷界Щ鍔紝绌虹櫧澶勬嫋鍔ㄧ敾甯�' },
+                    { key: 'marquee', label: '妗嗛��', desc: '鍦ㄧ敾甯冧腑妗嗛�変竴缁勫厓绱�' },
+                    { key: 'array', label: '闃靛垪', desc: '閫変腑涓�涓揣鏋� / 杞ㄩ亾鍚庢嫋涓�鏉$嚎鑷姩鐢熸垚涓�鎺�' },
+                    { key: 'pan', label: '骞崇Щ', desc: '涓撻棬鐢ㄤ簬鎷栧姩鐢诲竷鍜岃瀵熷叏鍥�' }
+                ],
+                drawTools: [
+                    { key: 'shelf', label: '璐ф灦', desc: '鑷敱鎷夊嚭璐ф灦鐭╁舰' },
+                    { key: 'devp', label: '杈撻�佺嚎', desc: '鎷夊嚭绔欑偣 / 杈撻�佺嚎鐭╁舰' },
+                    { key: 'crn', label: 'CRN', desc: '鎷夊嚭鍫嗗灈鏈鸿建閬撶煩褰�' },
+                    { key: 'dualCrn', label: '鍙屽伐浣�', desc: '鎷夊嚭鍙屽伐浣嶈建閬撶煩褰�' },
+                    { key: 'rgv', label: 'RGV', desc: '鎷夊嚭 RGV 杞ㄩ亾鐭╁舰' }
+                ],
+                pixiApp: null,
+                mapRoot: null,
+                gridLayer: null,
+                trackLayer: null,
+                nodeLayer: null,
+                patchObjectLayer: null,
+                activeLayer: null,
+                labelLayer: null,
+                selectionLayer: null,
+                guideLayer: null,
+                guideText: null,
+                hoverLayer: null,
+                labelPool: [],
+                renderQueued: false,
+                gridSceneDirty: true,
+                staticSceneDirty: true,
+                spatialIndexDirty: true,
+                spatialBuckets: null,
+                gridRenderRect: null,
+                gridRenderKey: '',
+                staticRenderRect: null,
+                staticRenderKey: '',
+                staticExcludedKey: '',
+                camera: {
+                    x: 80,
+                    y: 80,
+                    scale: 1
+                },
+                viewZoom: 1,
+                selectedIds: [],
+                clipboard: [],
+                hoverElementId: '',
+                pointerStatus: '--',
+                lastPointerStatusUpdateTs: 0,
+                pixiResolution: getPreferredResolution(),
+                fpsValue: 0,
+                fpsFrameCount: 0,
+                fpsSampleStartTs: 0,
+                fpsTickerHandler: null,
+                interactionState: null,
+                isZooming: false,
+                isPanning: false,
+                zoomRefreshTimer: null,
+                panRefreshTimer: null,
+                pendingViewportRefresh: false,
+                pendingStaticCommit: null,
+                deferredStaticRebuildTimer: null,
+                currentPointerId: null,
+                boundCanvasHandlers: null,
+                boundWindowHandlers: null,
+                resizeObserver: null,
+                labelCapability: {
+                    maxWidth: 0,
+                    maxHeight: 0
+                },
+                labelCapabilityDirty: true,
+                undoStack: [],
+                redoStack: [],
+                savedSnapshot: '',
+                isDirty: false,
+                saving: false,
+                savingAll: false,
+                loadingFloor: false,
+                switchingFloorLev: null,
+                floorRequestSeq: 0,
+                activeFloorRequestSeq: 0,
+                blankDialogVisible: false,
+                blankForm: {
+                    lev: '',
+                    width: String(DEFAULT_CANVAS_WIDTH),
+                    height: String(DEFAULT_CANVAS_HEIGHT)
+                },
+                canvasForm: {
+                    width: String(DEFAULT_CANVAS_WIDTH),
+                    height: String(DEFAULT_CANVAS_HEIGHT)
+                },
+                geometryForm: {
+                    x: '',
+                    y: '',
+                    width: '',
+                    height: ''
+                },
+                devpForm: {
+                    stationId: '',
+                    deviceNo: '',
+                    direction: [],
+                    isBarcodeStation: false,
+                    barcodeIdx: '',
+                    backStation: '',
+                    backStationDeviceNo: '',
+                    isInStation: false,
+                    barcodeStation: '',
+                    barcodeStationDeviceNo: '',
+                    isOutStation: false,
+                    runBlockReassign: false,
+                    isOutOrder: false,
+                    isLiftTransfer: false
+                },
+                deviceForm: {
+                    valueKey: '',
+                    deviceNo: ''
+                },
+                devpDirectionOptions: DEVP_DIRECTION_OPTIONS,
+                shelfFillForm: {
+                    startValue: '',
+                    rowStep: 'desc',
+                    colStep: 'asc'
+                },
+                valueEditorText: '',
+                spacePressed: false,
+                lastCursor: 'default'
+            };
+        },
+        computed: {
+            singleSelectedElement: function () {
+                if (!this.doc || this.selectedIds.length !== 1) {
+                    return null;
+                }
+                return this.findElementById(this.selectedIds[0]);
+            },
+            singleSelectedDeviceElement: function () {
+                if (!this.singleSelectedElement || !isDeviceConfigType(this.singleSelectedElement.type)) {
+                    return null;
+                }
+                return this.singleSelectedElement;
+            },
+            selectedShelfElements: function () {
+                if (!this.doc || !this.selectedIds.length) {
+                    return [];
+                }
+                return this.getSelectedElements().filter(function (item) {
+                    return item && item.type === 'shelf';
+                });
+            },
+            devpRequiresBarcodeLink: function () {
+                return !!(this.devpForm && this.devpForm.isInStation);
+            },
+            devpRequiresBarcodeIndex: function () {
+                return !!(this.devpForm && this.devpForm.isBarcodeStation);
+            },
+            devpRequiresBackStation: function () {
+                return !!(this.devpForm && this.devpForm.isBarcodeStation);
+            },
+            arrayPreviewCount: function () {
+                if (!this.interactionState || this.interactionState.type !== 'array') {
+                    return 0;
+                }
+                return this.interactionState.previewItems ? this.interactionState.previewItems.length : 0;
+            },
+            viewPercent: function () {
+                return Math.round(this.viewZoom * 100);
+            },
+            fpsText: function () {
+                return this.fpsValue > 0 ? String(this.fpsValue) : '--';
+            },
+            dirtyDraftLevs: function () {
+                var result = [];
+                var seen = {};
+                if (this.doc && this.doc.lev && this.isDirty) {
+                    var currentLev = toInt(this.doc.lev, 0);
+                    if (currentLev > 0) {
+                        seen[currentLev] = true;
+                        result.push(currentLev);
+                    }
+                }
+                var self = this;
+                Object.keys(this.draftDocs || {}).forEach(function (key) {
+                    var lev = toInt(key, 0);
+                    if (lev <= 0 || seen[lev]) {
+                        return;
+                    }
+                    if (self.hasDirtyDraft(lev)) {
+                        seen[lev] = true;
+                        result.push(lev);
+                    }
+                });
+                result.sort(function (a, b) { return a - b; });
+                return result;
+            },
+            dirtyDraftCount: function () {
+                return this.dirtyDraftLevs.length;
+            }
+        },
+        mounted: function () {
+            this.initPixi();
+            this.attachEvents();
+            this.loadLevOptions();
+            var lev = toInt(getQueryParam('lev'), 0);
+            if (lev > 0) {
+                this.floorPickerLev = lev;
+                this.fetchFloor(lev);
+            } else {
+                this.createLocalBlankDoc(1, DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT, '');
+            }
+        },
+        beforeDestroy: function () {
+            this.detachEvents();
+            if (this.zoomRefreshTimer) {
+                window.clearTimeout(this.zoomRefreshTimer);
+                this.zoomRefreshTimer = null;
+            }
+            if (this.panRefreshTimer) {
+                window.clearTimeout(this.panRefreshTimer);
+                this.panRefreshTimer = null;
+            }
+            this.clearDeferredStaticCommit();
+            this.stopFpsTicker();
+            if (this.pixiApp) {
+                this.pixiApp.destroy(true, { children: true });
+                this.pixiApp = null;
+            }
+        },
+        methods: {
+            showMessage: function (type, message) {
+                if (this.$message) {
+                    this.$message({ type: type, message: message });
+                }
+            },
+            formatNumber: function (value) {
+                var num = toNumber(value, 0);
+                if (Math.abs(num) >= 1000 || num === Math.round(num)) {
+                    return String(Math.round(num));
+                }
+                return String(Math.round(num * 100) / 100);
+            },
+            syncFloorQueryParam: function (lev) {
+                lev = toInt(lev, 0);
+                if (lev <= 0 || !window.history || !window.history.replaceState || !window.URL) {
+                    return;
+                }
+                try {
+                    var url = new URL(window.location.href);
+                    url.searchParams.set('lev', String(lev));
+                    window.history.replaceState(null, '', url.toString());
+                } catch (e) {
+                    // Ignore URL sync failures and keep editor usable.
+                }
+            },
+            toolLabel: function (tool) {
+                var list = this.interactionTools.concat(this.drawTools);
+                for (var i = 0; i < list.length; i++) {
+                    if (list[i].key === tool) {
+                        return list[i].label;
+                    }
+                }
+                return tool || '--';
+            },
+            toggleToolPanel: function () {
+                this.toolPanelCollapsed = !this.toolPanelCollapsed;
+            },
+            toggleInspectorPanel: function () {
+                this.inspectorPanelCollapsed = !this.inspectorPanelCollapsed;
+            },
+            startFpsTicker: function () {
+                if (!this.pixiApp || !this.pixiApp.ticker || this.fpsTickerHandler) {
+                    return;
+                }
+                var self = this;
+                this.fpsValue = 0;
+                this.fpsFrameCount = 0;
+                this.fpsSampleStartTs = (window.performance && performance.now) ? performance.now() : Date.now();
+                this.fpsTickerHandler = function () {
+                    var now = (window.performance && performance.now) ? performance.now() : Date.now();
+                    self.fpsFrameCount += 1;
+                    var elapsed = now - self.fpsSampleStartTs;
+                    if (elapsed < 400) {
+                        return;
+                    }
+                    self.fpsValue = Math.max(0, Math.round(self.fpsFrameCount * 1000 / elapsed));
+                    self.fpsFrameCount = 0;
+                    self.fpsSampleStartTs = now;
+                };
+                this.pixiApp.ticker.add(this.fpsTickerHandler);
+            },
+            stopFpsTicker: function () {
+                if (this.pixiApp && this.pixiApp.ticker && this.fpsTickerHandler) {
+                    this.pixiApp.ticker.remove(this.fpsTickerHandler);
+                }
+                this.fpsTickerHandler = null;
+                this.fpsFrameCount = 0;
+                this.fpsSampleStartTs = 0;
+            },
+            initPixi: function () {
+                var host = this.$refs.canvasHost;
+                if (!host) {
+                    return;
+                }
+                var resolution = getPreferredResolution();
+                this.pixiResolution = resolution;
+                var app = new PIXI.Application({
+                    width: Math.max(host.clientWidth, 320),
+                    height: Math.max(host.clientHeight, 320),
+                    antialias: false,
+                    autoDensity: true,
+                    backgroundAlpha: 1,
+                    backgroundColor: 0xf6f9fc,
+                    resolution: resolution,
+                    powerPreference: 'high-performance'
+                });
+                host.innerHTML = '';
+                host.appendChild(app.view);
+                app.view.style.width = '100%';
+                app.view.style.height = '100%';
+                app.view.style.touchAction = 'none';
+                app.view.style.background = '#f6f9fc';
+                app.renderer.roundPixels = true;
+
+                this.pixiApp = app;
+                this.mapRoot = new PIXI.Container();
+                app.stage.addChild(this.mapRoot);
+
+                this.gridLayer = new PIXI.Graphics();
+                this.staticLayer = new PIXI.Container();
+                this.staticTrackSpriteLayer = null;
+                this.staticNodeSpriteLayer = null;
+                this.trackLayer = new PIXI.Graphics();
+                this.nodeLayer = new PIXI.Graphics();
+                this.eraseLayer = new PIXI.Graphics();
+                this.patchObjectLayer = new PIXI.Graphics();
+                this.activeLayer = new PIXI.Graphics();
+                this.labelLayer = new PIXI.Container();
+                this.selectionLayer = new PIXI.Graphics();
+                this.guideLayer = new PIXI.Graphics();
+                this.guideText = new PIXI.Text('', {
+                    fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif',
+                    fontSize: 14,
+                    fontWeight: '700',
+                    fill: 0x1f4f86,
+                    stroke: 0xffffff,
+                    strokeThickness: 4,
+                    lineJoin: 'round'
+                });
+                this.guideText.anchor.set(0.5, 1);
+                this.guideText.visible = false;
+                this.hoverLayer = new PIXI.Graphics();
+                this.staticTrackSpritePool = [];
+                this.staticNodeSpritePool = [];
+
+                this.mapRoot.addChild(this.gridLayer);
+                this.staticTrackSpriteLayer = new PIXI.ParticleContainer(12000, {
+                    position: true,
+                    scale: true,
+                    alpha: true,
+                    tint: true
+                }, 16384, true);
+                this.staticNodeSpriteLayer = new PIXI.ParticleContainer(12000, {
+                    position: true,
+                    scale: true,
+                    alpha: true,
+                    tint: true
+                }, 16384, true);
+                this.staticLayer.addChild(this.staticTrackSpriteLayer);
+                this.staticLayer.addChild(this.trackLayer);
+                this.staticLayer.addChild(this.staticNodeSpriteLayer);
+                this.staticLayer.addChild(this.nodeLayer);
+                this.mapRoot.addChild(this.staticLayer);
+                this.mapRoot.addChild(this.eraseLayer);
+                this.mapRoot.addChild(this.patchObjectLayer);
+                this.mapRoot.addChild(this.activeLayer);
+                this.mapRoot.addChild(this.labelLayer);
+                this.mapRoot.addChild(this.hoverLayer);
+                this.mapRoot.addChild(this.selectionLayer);
+                this.mapRoot.addChild(this.guideLayer);
+                this.mapRoot.addChild(this.guideText);
+
+                this.boundCanvasHandlers = {
+                    pointerdown: this.onCanvasPointerDown.bind(this),
+                    wheel: this.onCanvasWheel.bind(this)
+                };
+                app.view.addEventListener('pointerdown', this.boundCanvasHandlers.pointerdown);
+                app.view.addEventListener('wheel', this.boundCanvasHandlers.wheel, { passive: false });
+
+                if (window.ResizeObserver) {
+                    this.resizeObserver = new ResizeObserver(this.handleResize.bind(this));
+                    this.resizeObserver.observe(host);
+                }
+                this.startFpsTicker();
+                this.handleResize();
+            },
+            attachEvents: function () {
+                this.boundWindowHandlers = {
+                    pointermove: this.onWindowPointerMove.bind(this),
+                    pointerup: this.onWindowPointerUp.bind(this),
+                    pointercancel: this.onWindowPointerUp.bind(this),
+                    keydown: this.onWindowKeyDown.bind(this),
+                    keyup: this.onWindowKeyUp.bind(this),
+                    beforeunload: this.onBeforeUnload.bind(this),
+                    resize: this.handleResize.bind(this)
+                };
+                window.addEventListener('pointermove', this.boundWindowHandlers.pointermove);
+                window.addEventListener('pointerup', this.boundWindowHandlers.pointerup);
+                window.addEventListener('pointercancel', this.boundWindowHandlers.pointercancel);
+                window.addEventListener('keydown', this.boundWindowHandlers.keydown);
+                window.addEventListener('keyup', this.boundWindowHandlers.keyup);
+                window.addEventListener('beforeunload', this.boundWindowHandlers.beforeunload);
+                window.addEventListener('resize', this.boundWindowHandlers.resize);
+            },
+            detachEvents: function () {
+                if (this.pixiApp && this.boundCanvasHandlers) {
+                    this.pixiApp.view.removeEventListener('pointerdown', this.boundCanvasHandlers.pointerdown);
+                    this.pixiApp.view.removeEventListener('wheel', this.boundCanvasHandlers.wheel);
+                }
+                if (this.resizeObserver) {
+                    this.resizeObserver.disconnect();
+                    this.resizeObserver = null;
+                }
+                if (!this.boundWindowHandlers) {
+                    return;
+                }
+                window.removeEventListener('pointermove', this.boundWindowHandlers.pointermove);
+                window.removeEventListener('pointerup', this.boundWindowHandlers.pointerup);
+                window.removeEventListener('pointercancel', this.boundWindowHandlers.pointercancel);
+                window.removeEventListener('keydown', this.boundWindowHandlers.keydown);
+                window.removeEventListener('keyup', this.boundWindowHandlers.keyup);
+                window.removeEventListener('beforeunload', this.boundWindowHandlers.beforeunload);
+                window.removeEventListener('resize', this.boundWindowHandlers.resize);
+            },
+            handleResize: function () {
+                if (!this.pixiApp || !this.$refs.canvasHost) {
+                    return;
+                }
+                var host = this.$refs.canvasHost;
+                var width = Math.max(host.clientWidth, 320);
+                var height = Math.max(host.clientHeight, 320);
+                this.pixiApp.renderer.resize(width, height);
+                this.markGridSceneDirty();
+                this.markStaticSceneDirty();
+                this.scheduleRender();
+            },
+            loadLevOptions: function () {
+                var self = this;
+                $.ajax({
+                    url: baseUrl + '/basMap/getLevList',
+                    method: 'GET',
+                    headers: authHeaders(),
+                    success: function (res) {
+                        if (res && res.code === 200 && Array.isArray(res.data)) {
+                            self.remoteLevOptions = res.data.map(function (item) {
+                                return toInt(item, 0);
+                            }).filter(function (item) {
+                                return item > 0;
+                            });
+                            self.refreshLevOptions();
+                        }
+                    }
+                });
+            },
+            refreshLevOptions: function () {
+                var set = {};
+                var result = [];
+                var pushLev = function (lev) {
+                    lev = toInt(lev, 0);
+                    if (lev <= 0 || set[lev]) {
+                        return;
+                    }
+                    set[lev] = true;
+                    result.push(lev);
+                };
+                this.remoteLevOptions.forEach(pushLev);
+                Object.keys(this.draftDocs || {}).forEach(pushLev);
+                pushLev(this.currentLev);
+                pushLev(this.floorPickerLev);
+                result.sort(function (a, b) { return a - b; });
+                this.levOptions = result;
+            },
+            exportDoc: function (doc) {
+                var source = doc || this.doc || {};
+                return {
+                    lev: toInt(source.lev, 0),
+                    editorMode: FREE_EDITOR_MODE,
+                    canvasWidth: roundCoord(toNumber(source.canvasWidth, DEFAULT_CANVAS_WIDTH)),
+                    canvasHeight: roundCoord(toNumber(source.canvasHeight, DEFAULT_CANVAS_HEIGHT)),
+                    elements: (source.elements || []).map(function (item, index) {
+                        return {
+                            id: item && item.id ? String(item.id) : ('el_' + (index + 1)),
+                            type: DRAW_TYPES.indexOf(item && item.type) >= 0 ? item.type : 'shelf',
+                            x: roundCoord(Math.max(0, toNumber(item && item.x, 0))),
+                            y: roundCoord(Math.max(0, toNumber(item && item.y, 0))),
+                            width: roundCoord(Math.max(MIN_ELEMENT_SIZE, toNumber(item && item.width, MIN_ELEMENT_SIZE))),
+                            height: roundCoord(Math.max(MIN_ELEMENT_SIZE, toNumber(item && item.height, MIN_ELEMENT_SIZE))),
+                            value: normalizeValue(item && item.value)
+                        };
+                    })
+                };
+            },
+            normalizeDoc: function (doc) {
+                var normalized = this.exportDoc(doc || {});
+                if (normalized.lev <= 0) {
+                    normalized.lev = toInt(this.currentLev, 1) || 1;
+                }
+                return normalized;
+            },
+            snapshotDoc: function (doc) {
+                return JSON.stringify(this.exportDoc(doc));
+            },
+            syncDirty: function () {
+                var currentSnapshot = this.snapshotDoc(this.doc);
+                this.isDirty = currentSnapshot !== this.savedSnapshot;
+            },
+            setDraftDocEntry: function (lev, doc, savedSnapshot) {
+                lev = toInt(lev, 0);
+                if (lev <= 0 || !doc) {
+                    return;
+                }
+                var entry = {
+                    doc: this.exportDoc(doc),
+                    savedSnapshot: savedSnapshot != null ? savedSnapshot : ''
+                };
+                if (this.$set) {
+                    this.$set(this.draftDocs, lev, entry);
+                } else {
+                    this.draftDocs[lev] = entry;
+                }
+            },
+            removeDraftDocEntry: function (lev) {
+                lev = toInt(lev, 0);
+                if (lev <= 0) {
+                    return;
+                }
+                if (this.$delete) {
+                    this.$delete(this.draftDocs, lev);
+                } else {
+                    delete this.draftDocs[lev];
+                }
+            },
+            cacheCurrentDraft: function () {
+                if (!this.doc || !this.doc.lev) {
+                    return;
+                }
+                this.setDraftDocEntry(this.doc.lev, this.doc, this.savedSnapshot);
+                this.refreshLevOptions();
+            },
+            clearCurrentDraftIfSaved: function () {
+                if (!this.doc || !this.doc.lev) {
+                    return;
+                }
+                this.setDraftDocEntry(this.doc.lev, this.doc, this.savedSnapshot);
+            },
+            clearFloorTransientState: function () {
+                this.clearDeferredStaticCommit();
+                this.interactionState = null;
+                this.currentPointerId = null;
+                this.hoverElementId = '';
+                this.pointerStatus = '--';
+                this.lastPointerStatusUpdateTs = 0;
+                this.selectedIds = [];
+                this.isPanning = false;
+                this.isZooming = false;
+                this.pendingViewportRefresh = false;
+                if (this.zoomRefreshTimer) {
+                    window.clearTimeout(this.zoomRefreshTimer);
+                    this.zoomRefreshTimer = null;
+                }
+                if (this.panRefreshTimer) {
+                    window.clearTimeout(this.panRefreshTimer);
+                    this.panRefreshTimer = null;
+                }
+            },
+            resetRenderLayers: function () {
+                if (this.gridLayer) {
+                    this.gridLayer.clear();
+                }
+                if (this.trackLayer) {
+                    this.trackLayer.clear();
+                }
+                if (this.nodeLayer) {
+                    this.nodeLayer.clear();
+                }
+                if (this.eraseLayer) {
+                    this.eraseLayer.clear();
+                }
+                if (this.patchObjectLayer) {
+                    this.patchObjectLayer.clear();
+                }
+                if (this.activeLayer) {
+                    this.activeLayer.clear();
+                }
+                if (this.selectionLayer) {
+                    this.selectionLayer.clear();
+                }
+                if (this.guideLayer) {
+                    this.guideLayer.clear();
+                }
+                if (this.hoverLayer) {
+                    this.hoverLayer.clear();
+                }
+                if (this.guideText) {
+                    this.guideText.visible = false;
+                    this.guideText.text = '';
+                }
+                if (this.labelLayer) {
+                    this.labelLayer.visible = false;
+                }
+                for (var i = 0; i < this.labelPool.length; i++) {
+                    this.labelPool[i].visible = false;
+                    this.labelPool[i].text = '';
+                }
+                this.hideUnusedStaticSprites(this.staticTrackSpritePool || [], 0);
+                this.hideUnusedStaticSprites(this.staticNodeSpritePool || [], 0);
+                if (this.staticTrackSpriteLayer) {
+                    this.staticTrackSpriteLayer.removeChildren();
+                    this.staticTrackSpritePool = [];
+                }
+                if (this.staticNodeSpriteLayer) {
+                    this.staticNodeSpriteLayer.removeChildren();
+                    this.staticNodeSpritePool = [];
+                }
+                if (this.staticTrackSpriteLayer) {
+                    this.staticTrackSpriteLayer.visible = false;
+                }
+                if (this.staticNodeSpriteLayer) {
+                    this.staticNodeSpriteLayer.visible = false;
+                }
+            },
+            hasDirtyDraft: function (lev) {
+                lev = toInt(lev, 0);
+                if (lev <= 0) {
+                    return false;
+                }
+                var entry = this.draftDocs[lev];
+                if (!entry || !entry.doc) {
+                    return false;
+                }
+                var snapshot = this.snapshotDoc(entry.doc);
+                return snapshot !== (entry.savedSnapshot || '');
+            },
+            markStaticSceneDirty: function () {
+                this.staticSceneDirty = true;
+            },
+            markGridSceneDirty: function () {
+                this.gridSceneDirty = true;
+            },
+            clearRenderCaches: function () {
+                this.gridRenderRect = null;
+                this.gridRenderKey = '';
+                this.staticRenderRect = null;
+                this.staticRenderKey = '';
+                this.staticExcludedKey = '';
+            },
+            scheduleZoomRefresh: function () {
+                if (this.zoomRefreshTimer) {
+                    window.clearTimeout(this.zoomRefreshTimer);
+                }
+                this.isZooming = true;
+                this.zoomRefreshTimer = window.setTimeout(function () {
+                    this.zoomRefreshTimer = null;
+                    this.isZooming = false;
+                    if (this.isPanning || (this.interactionState && this.interactionState.type === 'pan')) {
+                        this.pendingViewportRefresh = true;
+                        return;
+                    }
+                    this.markGridSceneDirty();
+                    this.markStaticSceneDirty();
+                    this.scheduleRender();
+                }.bind(this), ZOOM_REFRESH_DELAY);
+            },
+            cancelPanRefresh: function () {
+                if (this.panRefreshTimer) {
+                    window.clearTimeout(this.panRefreshTimer);
+                    this.panRefreshTimer = null;
+                }
+            },
+            schedulePanRefresh: function () {
+                this.cancelPanRefresh();
+                this.isPanning = true;
+                this.panRefreshTimer = window.setTimeout(function () {
+                    this.panRefreshTimer = null;
+                    this.isPanning = false;
+                    if (this.pendingViewportRefresh) {
+                        this.pendingViewportRefresh = false;
+                        this.markGridSceneDirty();
+                        this.markStaticSceneDirty();
+                    }
+                    this.scheduleRender();
+                }.bind(this), PAN_LABEL_REFRESH_DELAY);
+            },
+            rebuildLabelCapability: function () {
+                var maxWidth = 0;
+                var maxHeight = 0;
+                var elements = this.doc && this.doc.elements ? this.doc.elements : [];
+                for (var i = 0; i < elements.length; i++) {
+                    var element = elements[i];
+                    if (element.width > maxWidth) {
+                        maxWidth = element.width;
+                    }
+                    if (element.height > maxHeight) {
+                        maxHeight = element.height;
+                    }
+                }
+                this.labelCapability = {
+                    maxWidth: maxWidth,
+                    maxHeight: maxHeight
+                };
+                this.labelCapabilityDirty = false;
+            },
+            ensureLabelCapability: function () {
+                if (this.labelCapabilityDirty) {
+                    this.rebuildLabelCapability();
+                }
+                return this.labelCapability;
+            },
+            markSpatialIndexDirty: function () {
+                this.spatialIndexDirty = true;
+            },
+            rebuildSpatialIndex: function () {
+                var buckets = {};
+                var elements = this.doc && this.doc.elements ? this.doc.elements : [];
+                for (var i = 0; i < elements.length; i++) {
+                    var element = elements[i];
+                    var minX = Math.floor(element.x / SPATIAL_BUCKET_SIZE);
+                    var maxX = Math.floor((element.x + element.width) / SPATIAL_BUCKET_SIZE);
+                    var minY = Math.floor(element.y / SPATIAL_BUCKET_SIZE);
+                    var maxY = Math.floor((element.y + element.height) / SPATIAL_BUCKET_SIZE);
+                    for (var bx = minX; bx <= maxX; bx++) {
+                        for (var by = minY; by <= maxY; by++) {
+                            var key = bucketKey(bx, by);
+                            if (!buckets[key]) {
+                                buckets[key] = [];
+                            }
+                            buckets[key].push(element);
+                        }
+                    }
+                }
+                this.spatialBuckets = buckets;
+                this.spatialIndexDirty = false;
+            },
+            ensureSpatialIndex: function () {
+                if (this.spatialIndexDirty || !this.spatialBuckets) {
+                    this.rebuildSpatialIndex();
+                }
+            },
+            querySpatialCandidates: function (rect, padding, excludeIds) {
+                if (!this.doc || !rect) {
+                    return [];
+                }
+                this.ensureSpatialIndex();
+                var excludeMap = {};
+                excludeIds = excludeIds || [];
+                for (var i = 0; i < excludeIds.length; i++) {
+                    excludeMap[excludeIds[i]] = true;
+                }
+                var seen = {};
+                var result = [];
+                var pad = Math.max(0, padding || 0);
+                var minX = Math.floor((rect.x - pad) / SPATIAL_BUCKET_SIZE);
+                var maxX = Math.floor((rect.x + rect.width + pad) / SPATIAL_BUCKET_SIZE);
+                var minY = Math.floor((rect.y - pad) / SPATIAL_BUCKET_SIZE);
+                var maxY = Math.floor((rect.y + rect.height + pad) / SPATIAL_BUCKET_SIZE);
+                for (var bx = minX; bx <= maxX; bx++) {
+                    for (var by = minY; by <= maxY; by++) {
+                        var key = bucketKey(bx, by);
+                        var bucket = this.spatialBuckets[key];
+                        if (!bucket || !bucket.length) {
+                            continue;
+                        }
+                        for (var j = 0; j < bucket.length; j++) {
+                            var element = bucket[j];
+                            if (!element || seen[element.id] || excludeMap[element.id]) {
+                                continue;
+                            }
+                            seen[element.id] = true;
+                            result.push(element);
+                        }
+                    }
+                }
+                return result;
+            },
+            cancelDeferredStaticRebuild: function () {
+                if (this.deferredStaticRebuildTimer) {
+                    window.clearTimeout(this.deferredStaticRebuildTimer);
+                    this.deferredStaticRebuildTimer = null;
+                }
+            },
+            stageDeferredStaticCommit: function (ids, eraseRects) {
+                this.pendingStaticCommit = {
+                    ids: (ids || []).slice(),
+                    eraseRects: (eraseRects || []).map(function (item) {
+                        return {
+                            x: item.x,
+                            y: item.y,
+                            width: item.width,
+                            height: item.height
+                        };
+                    })
+                };
+            },
+            clearDeferredStaticCommit: function () {
+                this.cancelDeferredStaticRebuild();
+                this.pendingStaticCommit = null;
+            },
+            scheduleDeferredStaticRebuild: function () {
+                this.cancelDeferredStaticRebuild();
+                this.deferredStaticRebuildTimer = window.setTimeout(function () {
+                    this.deferredStaticRebuildTimer = null;
+                    this.pendingStaticCommit = null;
+                    this.markStaticSceneDirty();
+                    this.scheduleRender();
+                }.bind(this), DEFERRED_STATIC_REBUILD_DELAY);
+            },
+            selectionKey: function (ids) {
+                return (ids || []).slice().sort().join('|');
+            },
+            setSelectedIds: function (ids, options) {
+                options = options || {};
+                var nextIds = (ids || []).filter(Boolean);
+                this.selectedIds = nextIds.slice();
+                if (options.refreshInspector !== false) {
+                    this.refreshInspector();
+                }
+            },
+            setCurrentDoc: function (doc, options) {
+                options = options || {};
+                var normalized = this.normalizeDoc(doc);
+                this.clearFloorTransientState();
+                this.resetRenderLayers();
+                this.clearRenderCaches();
+                this.doc = normalized;
+                this.markSpatialIndexDirty();
+                this.labelCapabilityDirty = true;
+                this.pendingViewportRefresh = false;
+                this.currentLev = normalized.lev;
+                this.floorPickerLev = normalized.lev;
+                this.switchingFloorLev = null;
+                this.loadingFloor = false;
+                this.syncFloorQueryParam(normalized.lev);
+                this.markGridSceneDirty();
+                this.markStaticSceneDirty();
+                this.undoStack = [];
+                this.redoStack = [];
+                this.savedSnapshot = options.savedSnapshot != null ? options.savedSnapshot : this.snapshotDoc(normalized);
+                this.syncDirty();
+                this.refreshInspector();
+                this.refreshLevOptions();
+                this.$nextTick(function () {
+                    this.fitContent();
+                    this.scheduleRender();
+                }.bind(this));
+            },
+            replaceDocFromSnapshot: function (snapshot) {
+                if (!snapshot) {
+                    return;
+                }
+                try {
+                    this.clearFloorTransientState();
+                    this.resetRenderLayers();
+                    this.clearRenderCaches();
+                    this.doc = this.normalizeDoc(JSON.parse(snapshot));
+                    this.markSpatialIndexDirty();
+                    this.labelCapabilityDirty = true;
+                    this.pendingViewportRefresh = false;
+                } catch (e) {
+                    this.showMessage('error', '鍘嗗彶璁板綍鎭㈠澶辫触');
+                    return;
+                }
+                this.markGridSceneDirty();
+                this.markStaticSceneDirty();
+                this.floorPickerLev = this.doc.lev;
+                this.currentLev = this.doc.lev;
+                this.refreshInspector();
+                this.syncDirty();
+                this.cacheCurrentDraft();
+                this.scheduleRender();
+            },
+            pushUndoSnapshot: function (snapshot) {
+                if (!snapshot) {
+                    return;
+                }
+                if (this.undoStack.length > 0 && this.undoStack[this.undoStack.length - 1] === snapshot) {
+                    return;
+                }
+                this.undoStack.push(snapshot);
+                if (this.undoStack.length > HISTORY_LIMIT) {
+                    this.undoStack.shift();
+                }
+            },
+            commitMutation: function (beforeSnapshot, options) {
+                options = options || {};
+                var afterSnapshot = this.snapshotDoc(this.doc);
+                if (beforeSnapshot === afterSnapshot) {
+                    this.scheduleRender();
+                    this.refreshInspector();
+                    return false;
+                }
+                this.pushUndoSnapshot(beforeSnapshot);
+                this.redoStack = [];
+                this.markSpatialIndexDirty();
+                this.labelCapabilityDirty = true;
+                if (options.staticSceneDirty !== false) {
+                    this.clearDeferredStaticCommit();
+                    this.markStaticSceneDirty();
+                }
+                this.syncDirty();
+                this.cacheCurrentDraft();
+                this.refreshInspector();
+                this.scheduleRender();
+                return true;
+            },
+            runMutation: function (mutator) {
+                if (!this.doc) {
+                    return false;
+                }
+                var beforeSnapshot = this.snapshotDoc(this.doc);
+                mutator();
+                return this.commitMutation(beforeSnapshot);
+            },
+            undo: function () {
+                if (this.undoStack.length === 0 || !this.doc) {
+                    return;
+                }
+                var currentSnapshot = this.snapshotDoc(this.doc);
+                var snapshot = this.undoStack.pop();
+                this.redoStack.push(currentSnapshot);
+                this.replaceDocFromSnapshot(snapshot);
+            },
+            redo: function () {
+                if (this.redoStack.length === 0 || !this.doc) {
+                    return;
+                }
+                var currentSnapshot = this.snapshotDoc(this.doc);
+                var snapshot = this.redoStack.pop();
+                this.pushUndoSnapshot(currentSnapshot);
+                this.replaceDocFromSnapshot(snapshot);
+            },
+            createLocalBlankDoc: function (lev, width, height, savedSnapshot) {
+                var doc = {
+                    lev: toInt(lev, 1),
+                    editorMode: FREE_EDITOR_MODE,
+                    canvasWidth: Math.max(MIN_ELEMENT_SIZE * 4, toNumber(width, DEFAULT_CANVAS_WIDTH)),
+                    canvasHeight: Math.max(MIN_ELEMENT_SIZE * 4, toNumber(height, DEFAULT_CANVAS_HEIGHT)),
+                    elements: []
+                };
+                this.setCurrentDoc(doc, {
+                    savedSnapshot: savedSnapshot != null ? savedSnapshot : ''
+                });
+                this.cacheCurrentDraft();
+                this.syncDirty();
+            },
+            openBlankDialog: function () {
+                var lev = this.currentLev || 1;
+                this.blankForm = {
+                    lev: String(lev),
+                    width: String(Math.round(this.doc ? this.doc.canvasWidth : DEFAULT_CANVAS_WIDTH)),
+                    height: String(Math.round(this.doc ? this.doc.canvasHeight : DEFAULT_CANVAS_HEIGHT))
+                };
+                this.blankDialogVisible = true;
+            },
+            createBlankMap: function () {
+                var lev = toInt(this.blankForm.lev, 0);
+                var width = toNumber(this.blankForm.width, DEFAULT_CANVAS_WIDTH);
+                var height = toNumber(this.blankForm.height, DEFAULT_CANVAS_HEIGHT);
+                if (lev <= 0) {
+                    this.showMessage('warning', '妤煎眰涓嶈兘涓虹┖');
+                    return;
+                }
+                if (width <= 0 || height <= 0) {
+                    this.showMessage('warning', '鐢诲竷灏哄蹇呴』澶т簬 0');
+                    return;
+                }
+                this.blankDialogVisible = false;
+                this.createLocalBlankDoc(lev, width, height, '');
+            },
+            buildTransferPayload: function () {
+                var doc = this.exportDoc(this.doc);
+                return {
+                    format: MAP_TRANSFER_FORMAT,
+                    exportedAt: new Date().toISOString(),
+                    source: {
+                        lev: doc.lev,
+                        editorMode: doc.editorMode
+                    },
+                    docs: [doc]
+                };
+            },
+            buildTransferFilename: function (docs) {
+                var levs = (docs || []).map(function (item) {
+                    return toInt(item && item.lev, 0);
+                }).filter(function (lev) {
+                    return lev > 0;
+                }).sort(function (a, b) {
+                    return a - b;
+                });
+                var scope = levs.length <= 1
+                    ? (String(levs[0] || (this.currentLev || 1)) + 'F')
+                    : ('all-' + levs.length + '-floors');
+                var now = new Date();
+                return [
+                    'bas-map',
+                    scope,
+                    now.getFullYear(),
+                    padNumber(now.getMonth() + 1),
+                    padNumber(now.getDate()),
+                    padNumber(now.getHours()),
+                    padNumber(now.getMinutes()),
+                    padNumber(now.getSeconds())
+                ].join('-') + '.json';
+            },
+            requestEditorDoc: function (lev) {
+                return new Promise(function (resolve, reject) {
+                    $.ajax({
+                        url: baseUrl + '/basMap/editor/' + lev + '/auth',
+                        method: 'GET',
+                        headers: authHeaders(),
+                        success: function (res) {
+                            if (!res || res.code !== 200 || !res.data) {
+                                reject(new Error((res && res.msg) ? res.msg : ('鍔犺浇 ' + lev + 'F 鍦板浘澶辫触')));
+                                return;
+                            }
+                            resolve(res.data);
+                        },
+                        error: function () {
+                            reject(new Error('鍔犺浇 ' + lev + 'F 鍦板浘澶辫触'));
+                        }
+                    });
+                });
+            },
+            collectAllTransferDocs: function () {
+                var self = this;
+                var levMap = {};
+                (this.remoteLevOptions || []).forEach(function (lev) {
+                    lev = toInt(lev, 0);
+                    if (lev > 0) {
+                        levMap[lev] = true;
+                    }
+                });
+                Object.keys(this.draftDocs || {}).forEach(function (key) {
+                    var lev = toInt(key, 0);
+                    if (lev > 0) {
+                        levMap[lev] = true;
+                    }
+                });
+                if (this.doc && this.doc.lev) {
+                    levMap[toInt(this.doc.lev, 0)] = true;
+                }
+                var levs = Object.keys(levMap).map(function (key) {
+                    return toInt(key, 0);
+                }).filter(function (lev) {
+                    return lev > 0;
+                }).sort(function (a, b) {
+                    return a - b;
+                });
+                if (!levs.length) {
+                    return Promise.resolve([]);
+                }
+                return Promise.all(levs.map(function (lev) {
+                    if (self.doc && self.doc.lev === lev) {
+                        return Promise.resolve(self.exportDoc(self.doc));
+                    }
+                    if (self.draftDocs[lev] && self.draftDocs[lev].doc) {
+                        return Promise.resolve(self.exportDoc(self.draftDocs[lev].doc));
+                    }
+                    return self.requestEditorDoc(lev).then(function (doc) {
+                        return self.normalizeDoc(doc);
+                    });
+                }));
+            },
+            exportMapPackage: function () {
+                var self = this;
+                if (!this.doc && (!this.remoteLevOptions || !this.remoteLevOptions.length)) {
+                    this.showMessage('warning', '褰撳墠娌℃湁鍙鍑虹殑鍦板浘');
+                    return;
+                }
+                this.collectAllTransferDocs().then(function (docs) {
+                    if (!docs || !docs.length) {
+                        self.showMessage('warning', '褰撳墠娌℃湁鍙鍑虹殑鍦板浘');
+                        return;
+                    }
+                    var payload = {
+                        format: MAP_TRANSFER_FORMAT,
+                        exportedAt: new Date().toISOString(),
+                        source: {
+                            lev: self.currentLev || (docs[0] && docs[0].lev) || 1,
+                            editorMode: FREE_EDITOR_MODE
+                        },
+                        docs: docs.map(function (doc) {
+                            return self.exportDoc(doc);
+                        })
+                    };
+                    var blob = new Blob([JSON.stringify(payload, null, 2)], {
+                        type: 'application/json;charset=utf-8'
+                    });
+                    var href = window.URL.createObjectURL(blob);
+                    var link = document.createElement('a');
+                    link.href = href;
+                    link.download = self.buildTransferFilename(payload.docs);
+                    document.body.appendChild(link);
+                    link.click();
+                    document.body.removeChild(link);
+                    window.setTimeout(function () {
+                        window.URL.revokeObjectURL(href);
+                    }, 0);
+                    self.showMessage('success', '宸插鍑� ' + payload.docs.length + ' 涓ゼ灞傜殑鍦板浘鍖�');
+                }).catch(function (error) {
+                    self.showMessage('error', error && error.message ? error.message : '瀵煎嚭鍦板浘澶辫触');
+                });
+            },
+            triggerImportMap: function () {
+                if (this.$refs.mapImportInput) {
+                    this.$refs.mapImportInput.value = '';
+                    this.$refs.mapImportInput.click();
+                }
+            },
+            parseTransferPackage: function (raw) {
+                if (!raw) {
+                    return null;
+                }
+                if (raw.format === MAP_TRANSFER_FORMAT && Array.isArray(raw.docs) && raw.docs.length) {
+                    return {
+                        docs: raw.docs,
+                        activeLev: toInt(raw.source && raw.source.lev, 0)
+                    };
+                }
+                if ((raw.format === 'bas-map-editor-transfer-v1' || raw.format === MAP_TRANSFER_FORMAT) && raw.doc) {
+                    return {
+                        docs: [raw.doc],
+                        activeLev: toInt(raw.source && raw.source.lev, 0)
+                    };
+                }
+                if (raw.editorMode === FREE_EDITOR_MODE && Array.isArray(raw.elements)) {
+                    return {
+                        docs: [raw],
+                        activeLev: toInt(raw.lev, 0)
+                    };
+                }
+                return null;
+            },
+            importMapPackage: function (payload, options) {
+                options = options || {};
+                if (!payload || !Array.isArray(payload.docs) || !payload.docs.length) {
+                    this.showMessage('error', '瀵煎叆鏂囦欢鏍煎紡涓嶆纭�');
+                    return;
+                }
+                if (this.isDirty && options.skipConfirm !== true) {
+                    if (!window.confirm('瀵煎叆鍦板浘浼氭浛鎹㈠綋鍓嶇紪杈戞�佹湭淇濆瓨鍐呭锛屾槸鍚︾户缁紵')) {
+                        return;
+                    }
+                }
+                if (this.doc) {
+                    this.cacheCurrentDraft();
+                }
+                var self = this;
+                var normalizedDocs = payload.docs.map(function (item) {
+                    return self.normalizeDoc(item);
+                }).sort(function (a, b) {
+                    return toInt(a.lev, 0) - toInt(b.lev, 0);
+                });
+                normalizedDocs.forEach(function (doc) {
+                    self.setDraftDocEntry(doc.lev, doc, '');
+                });
+                var activeLev = toInt(payload.activeLev, 0);
+                var targetDoc = normalizedDocs[0];
+                for (var i = 0; i < normalizedDocs.length; i++) {
+                    if (normalizedDocs[i].lev === activeLev) {
+                        targetDoc = normalizedDocs[i];
+                        break;
+                    }
+                }
+                this.refreshLevOptions();
+                this.floorPickerLev = targetDoc.lev;
+                this.setCurrentDoc(targetDoc, { savedSnapshot: '' });
+                if (normalizedDocs.length > 1) {
+                    this.showMessage('success', '鍦板浘鍖呭凡瀵煎叆 ' + normalizedDocs.length + ' 涓ゼ灞傦紝鍙偣鍑烩�滀繚瀛樺叏閮ㄦゼ灞傗�濊惤搴�');
+                    return;
+                }
+                this.showMessage('success', '鍦板浘鍖呭凡瀵煎叆锛屼繚瀛樺悗鎵嶄細瑕嗙洊杩愯鍦板浘');
+            },
+            handleImportMap: function (event) {
+                var file = event && event.target && event.target.files ? event.target.files[0] : null;
+                if (!file) {
+                    return;
+                }
+                var self = this;
+                var reader = new FileReader();
+                reader.onload = function (loadEvent) {
+                    try {
+                        var text = loadEvent && loadEvent.target ? loadEvent.target.result : '';
+                        var raw = JSON.parse(text || '{}');
+                        var payload = self.parseTransferPackage(raw);
+                        self.importMapPackage(payload);
+                    } catch (e) {
+                        self.showMessage('error', '鍦板浘鏂囦欢瑙f瀽澶辫触');
+                    }
+                };
+                reader.onerror = function () {
+                    self.showMessage('error', '鍦板浘鏂囦欢璇诲彇澶辫触');
+                };
+                reader.readAsText(file, 'utf-8');
+            },
+            triggerImportExcel: function () {
+                if (this.$refs.importInput) {
+                    this.$refs.importInput.value = '';
+                    this.$refs.importInput.click();
+                }
+            },
+            handleImportExcel: function (event) {
+                var self = this;
+                var file = event && event.target && event.target.files ? event.target.files[0] : null;
+                if (!file) {
+                    return;
+                }
+                var formData = new FormData();
+                formData.append('file', file);
+                $.ajax({
+                    url: baseUrl + '/basMap/editor/importExcel/auth',
+                    method: 'POST',
+                    headers: authHeaders(),
+                    data: formData,
+                    processData: false,
+                    contentType: false,
+                    success: function (res) {
+                        if (!res || res.code !== 200 || !Array.isArray(res.data) || res.data.length === 0) {
+                            self.showMessage('error', (res && res.msg) ? res.msg : 'Excel 瀵煎叆澶辫触');
+                            return;
+                        }
+                        res.data.forEach(function (item) {
+                            var doc = self.normalizeDoc(item);
+                            self.setDraftDocEntry(doc.lev, doc, '');
+                        });
+                        self.refreshLevOptions();
+                        self.floorPickerLev = toInt(res.data[0].lev, 0);
+                        self.setCurrentDoc(res.data[0], { savedSnapshot: '' });
+                        self.showMessage('success', 'Excel 宸插鍏ュ埌缂栬緫鍣紝淇濆瓨鍚庢墠浼氳鐩栬繍琛屽湴鍥�');
+                    },
+                    error: function () {
+                        self.showMessage('error', 'Excel 瀵煎叆澶辫触');
+                    }
+                });
+            },
+            handleFloorChange: function (lev) {
+                lev = toInt(lev, 0);
+                if (lev <= 0) {
+                    return;
+                }
+                this.floorPickerLev = lev;
+                if (this.doc && this.doc.lev === lev && !this.loadingFloor) {
+                    this.switchingFloorLev = null;
+                    return;
+                }
+                if (this.doc) {
+                    this.cacheCurrentDraft();
+                }
+                this.clearFloorTransientState();
+                this.resetRenderLayers();
+                this.switchingFloorLev = lev;
+                this.markGridSceneDirty();
+                this.markStaticSceneDirty();
+                this.scheduleRender();
+                this.fetchFloor(lev);
+            },
+            loadCurrentFloor: function () {
+                if (!this.currentLev) {
+                    this.showMessage('warning', '璇峰厛閫夋嫨妤煎眰');
+                    return;
+                }
+                if (this.isDirty && !window.confirm('閲嶆柊璇诲彇浼氫涪寮冨綋鍓嶆ゼ灞傛湭淇濆瓨鐨勮嚜鐢辩敾甯冪紪杈戯紝鏄惁缁х画锛�')) {
+                    return;
+                }
+                this.removeDraftDocEntry(this.currentLev);
+                this.refreshLevOptions();
+                this.fetchFloor(this.currentLev);
+            },
+            fetchFloor: function (lev) {
+                var self = this;
+                lev = toInt(lev, 0);
+                if (lev <= 0) {
+                    return;
+                }
+                var requestSeq = ++this.floorRequestSeq;
+                this.activeFloorRequestSeq = requestSeq;
+                this.loadingFloor = true;
+                this.switchingFloorLev = lev;
+                $.ajax({
+                    url: baseUrl + '/basMap/editor/' + lev + '/auth',
+                    method: 'GET',
+                    headers: authHeaders(),
+                    success: function (res) {
+                        if (requestSeq !== self.activeFloorRequestSeq) {
+                            return;
+                        }
+                        self.loadingFloor = false;
+                        if (!res || res.code !== 200 || !res.data) {
+                            self.switchingFloorLev = null;
+                            self.floorPickerLev = self.currentLev;
+                            self.markGridSceneDirty();
+                            self.markStaticSceneDirty();
+                            self.scheduleRender();
+                            self.showMessage('error', (res && res.msg) ? res.msg : '鍔犺浇鍦板浘澶辫触');
+                            return;
+                        }
+                        var normalized = self.normalizeDoc(res.data);
+                        self.setDraftDocEntry(normalized.lev, normalized, self.snapshotDoc(normalized));
+                        self.setCurrentDoc(normalized, {
+                            savedSnapshot: self.snapshotDoc(normalized)
+                        });
+                    },
+                    error: function () {
+                        if (requestSeq !== self.activeFloorRequestSeq) {
+                            return;
+                        }
+                        self.loadingFloor = false;
+                        self.switchingFloorLev = null;
+                        self.floorPickerLev = self.currentLev;
+                        self.markGridSceneDirty();
+                        self.markStaticSceneDirty();
+                        self.scheduleRender();
+                        self.showMessage('error', '鍔犺浇鍦板浘澶辫触');
+                    }
+                });
+            },
+            validateDocBeforeSave: function (doc) {
+                var source = this.normalizeDoc(doc);
+                if (!source || !source.lev) {
+                    return '妤煎眰涓嶈兘涓虹┖';
+                }
+                if (toNumber(source.canvasWidth, 0) <= 0 || toNumber(source.canvasHeight, 0) <= 0) {
+                    return '鐢诲竷灏哄蹇呴』澶т簬 0';
+                }
+                var elements = source.elements || [];
+                for (var i = 0; i < elements.length; i++) {
+                    var element = elements[i];
+                    if (element.width <= 0 || element.height <= 0) {
+                        return '瀛樺湪灏哄鏃犳晥鐨勫厓绱�';
+                    }
+                    if (element.x < 0 || element.y < 0) {
+                        return '鍏冪礌鍧愭爣涓嶈兘灏忎簬 0';
+                    }
+                    if (!isRectWithinCanvas(element, source.canvasWidth, source.canvasHeight)) {
+                        return '瀛樺湪瓒呭嚭鐢诲竷杈圭晫鐨勫厓绱�: ' + element.id;
+                    }
+                    if (element.type === 'devp') {
+                        var value = safeParseJson(element.value);
+                        if (!value || toInt(value.stationId, 0) <= 0 || toInt(value.deviceNo, 0) <= 0) {
+                            return '杈撻�佺嚎鍏冪礌蹇呴』閰嶇疆鏈夋晥鐨� stationId 鍜� deviceNo';
+                        }
+                    }
+                }
+                var overlapId = findDocOverlapId(source);
+                if (overlapId) {
+                    return '瀛樺湪閲嶅彔鍏冪礌: ' + overlapId;
+                }
+                return '';
+            },
+            validateBeforeSave: function () {
+                return this.validateDocBeforeSave(this.doc);
+            },
+            requestSaveDoc: function (doc) {
+                return new Promise(function (resolve, reject) {
+                    $.ajax({
+                        url: baseUrl + '/basMap/editor/save/auth',
+                        method: 'POST',
+                        headers: $.extend({
+                            'Content-Type': 'application/json;charset=UTF-8'
+                        }, authHeaders()),
+                        data: JSON.stringify(doc),
+                        success: function (res) {
+                            if (!res || res.code !== 200) {
+                                reject(new Error((res && res.msg) ? res.msg : '淇濆瓨澶辫触'));
+                                return;
+                            }
+                            resolve(res);
+                        },
+                        error: function () {
+                            reject(new Error('淇濆瓨澶辫触'));
+                        }
+                    });
+                });
+            },
+            collectDirtyDocsForSave: function () {
+                var result = [];
+                var seen = {};
+                if (this.doc && this.doc.lev && this.isDirty) {
+                    var currentDoc = this.exportDoc(this.doc);
+                    result.push(currentDoc);
+                    seen[currentDoc.lev] = true;
+                }
+                var self = this;
+                Object.keys(this.draftDocs || {}).forEach(function (key) {
+                    var lev = toInt(key, 0);
+                    if (lev <= 0 || seen[lev]) {
+                        return;
+                    }
+                    var entry = self.draftDocs[lev];
+                    if (!entry || !entry.doc) {
+                        return;
+                    }
+                    var snapshot = self.snapshotDoc(entry.doc);
+                    if (snapshot === (entry.savedSnapshot || '')) {
+                        return;
+                    }
+                    var doc = self.exportDoc(entry.doc);
+                    result.push(doc);
+                    seen[doc.lev] = true;
+                });
+                result.sort(function (a, b) {
+                    return toInt(a.lev, 0) - toInt(b.lev, 0);
+                });
+                return result;
+            },
+            markDocSavedState: function (doc) {
+                var normalized = this.normalizeDoc(doc);
+                var savedSnapshot = this.snapshotDoc(normalized);
+                this.setDraftDocEntry(normalized.lev, normalized, savedSnapshot);
+                if (this.doc && this.doc.lev === normalized.lev) {
+                    this.savedSnapshot = savedSnapshot;
+                    this.syncDirty();
+                }
+            },
+            saveDoc: function () {
+                var self = this;
+                if (!this.doc) {
+                    return;
+                }
+                var error = this.validateBeforeSave();
+                if (error) {
+                    this.showMessage('warning', error);
+                    return;
+                }
+                this.saving = true;
+                var payload = this.exportDoc(this.doc);
+                this.requestSaveDoc(payload).then(function () {
+                    self.saving = false;
+                        self.savedSnapshot = self.snapshotDoc(self.doc);
+                        self.syncDirty();
+                        self.clearCurrentDraftIfSaved();
+                        self.refreshLevOptions();
+                    self.showMessage('success', '褰撳墠妤煎眰宸蹭繚瀛樺苟缂栬瘧鍒拌繍琛屽湴鍥�');
+                }).catch(function (error) {
+                        self.saving = false;
+                    self.showMessage('error', error && error.message ? error.message : '淇濆瓨澶辫触');
+                });
+            },
+            saveAllDocs: function () {
+                var self = this;
+                if (this.saving || this.savingAll) {
+                    return;
+                }
+                var docs = this.collectDirtyDocsForSave();
+                if (!docs.length) {
+                    this.showMessage('warning', '褰撳墠娌℃湁闇�瑕佷繚瀛樼殑妤煎眰');
+                    return;
+                }
+                if (docs.length > 1 && !window.confirm('灏嗕繚瀛� ' + docs.length + ' 涓ゼ灞傚埌杩愯鍦板浘锛屾槸鍚︾户缁紵')) {
+                    return;
+                }
+                for (var i = 0; i < docs.length; i++) {
+                    var error = this.validateDocBeforeSave(docs[i]);
+                    if (error) {
+                        this.showMessage('warning', docs[i].lev + 'F 淇濆瓨鍓嶆牎楠屽け璐�: ' + error);
+                        return;
+                    }
+                }
+                this.savingAll = true;
+                var index = 0;
+                var total = docs.length;
+                var next = function () {
+                    if (index >= total) {
+                        self.savingAll = false;
+                        self.refreshLevOptions();
+                        self.showMessage('success', '宸蹭繚瀛� ' + total + ' 涓ゼ灞傚埌杩愯鍦板浘');
+                        return;
+                    }
+                    var doc = docs[index++];
+                    self.requestSaveDoc(doc).then(function () {
+                        self.markDocSavedState(doc);
+                        next();
+                    }).catch(function (error) {
+                        self.savingAll = false;
+                        self.showMessage('error', doc.lev + 'F 淇濆瓨澶辫触: ' + (error && error.message ? error.message : '淇濆瓨澶辫触'));
+                    });
+                };
+                next();
+            },
+            setTool: function (tool) {
+                this.activeTool = tool;
+                this.updateCursor();
+            },
+            findElementById: function (id) {
+                if (!this.doc || !id) {
+                    return null;
+                }
+                var elements = this.doc.elements || [];
+                for (var i = 0; i < elements.length; i++) {
+                    if (elements[i].id === id) {
+                        return elements[i];
+                    }
+                }
+                return null;
+            },
+            getSelectedElements: function () {
+                var self = this;
+                return this.selectedIds.map(function (id) {
+                    return self.findElementById(id);
+                }).filter(Boolean);
+            },
+            refreshInspector: function () {
+                var element = this.singleSelectedElement;
+                if (!this.doc) {
+                    this.canvasForm = {
+                        width: String(DEFAULT_CANVAS_WIDTH),
+                        height: String(DEFAULT_CANVAS_HEIGHT)
+                    };
+                    this.valueEditorText = '';
+                    this.resetDevpForm();
+                    this.resetDeviceForm();
+                    return;
+                }
+                this.canvasForm = {
+                    width: String(Math.round(this.doc.canvasWidth)),
+                    height: String(Math.round(this.doc.canvasHeight))
+                };
+                if (!element) {
+                    this.geometryForm = { x: '', y: '', width: '', height: '' };
+                    this.valueEditorText = '';
+                    this.resetDevpForm();
+                    this.resetDeviceForm();
+                    return;
+                }
+                this.geometryForm = {
+                    x: String(this.formatNumber(element.x)),
+                    y: String(this.formatNumber(element.y)),
+                    width: String(this.formatNumber(element.width)),
+                    height: String(this.formatNumber(element.height))
+                };
+                this.valueEditorText = element.value || '';
+                if (element.type === 'devp') {
+                    this.loadDevpForm(element.value);
+                } else {
+                    this.resetDevpForm();
+                }
+                if (isDeviceConfigType(element.type)) {
+                    this.loadDeviceForm(element.type, element.value);
+                } else {
+                    this.resetDeviceForm();
+                }
+                this.ensureShelfFillStartValue();
+            },
+            resetDevpForm: function () {
+                this.devpForm = {
+                    stationId: '',
+                    deviceNo: '',
+                    direction: [],
+                    isBarcodeStation: false,
+                    barcodeIdx: '',
+                    backStation: '',
+                    backStationDeviceNo: '',
+                    isInStation: false,
+                    barcodeStation: '',
+                    barcodeStationDeviceNo: '',
+                    isOutStation: false,
+                    runBlockReassign: false,
+                    isOutOrder: false,
+                    isLiftTransfer: false
+                };
+            },
+            resetDeviceForm: function () {
+                this.deviceForm = {
+                    valueKey: '',
+                    deviceNo: ''
+                };
+            },
+            ensureShelfFillStartValue: function () {
+                var element = this.singleSelectedElement;
+                if (!element || element.type !== 'shelf') {
+                    return;
+                }
+                if (!this.shelfFillForm.startValue || !parseShelfLocationValue(this.shelfFillForm.startValue)) {
+                    this.shelfFillForm.startValue = normalizeValue(element.value || '');
+                }
+            },
+            loadDevpForm: function (value) {
+                this.resetDevpForm();
+                var json = safeParseJson(value);
+                if (!json) {
+                    return;
+                }
+                this.devpForm.stationId = json.stationId != null ? String(json.stationId) : '';
+                this.devpForm.deviceNo = json.deviceNo != null ? String(json.deviceNo) : '';
+                this.devpForm.direction = normalizeDirectionList(json.direction);
+                this.devpForm.isBarcodeStation = boolFlag(json.isBarcodeStation);
+                this.devpForm.barcodeIdx = json.barcodeIdx != null ? String(json.barcodeIdx) : '';
+                this.devpForm.backStation = json.backStation != null ? String(json.backStation) : '';
+                this.devpForm.backStationDeviceNo = json.backStationDeviceNo != null ? String(json.backStationDeviceNo) : '';
+                this.devpForm.isInStation = boolFlag(json.isInStation);
+                this.devpForm.barcodeStation = json.barcodeStation != null ? String(json.barcodeStation) : '';
+                this.devpForm.barcodeStationDeviceNo = json.barcodeStationDeviceNo != null ? String(json.barcodeStationDeviceNo) : '';
+                this.devpForm.isOutStation = boolFlag(json.isOutStation);
+                this.devpForm.runBlockReassign = boolFlag(json.runBlockReassign);
+                this.devpForm.isOutOrder = boolFlag(json.isOutOrder);
+                this.devpForm.isLiftTransfer = boolFlag(json.isLiftTransfer);
+            },
+            getDeviceConfigLabel: function (type) {
+                var meta = getTypeMeta(type);
+                return meta.label + '鍙傛暟';
+            },
+            getDeviceConfigKeyLabel: function (type, valueKey) {
+                if (valueKey === 'crnNo') {
+                    return 'crnNo';
+                }
+                if (valueKey === 'rgvNo') {
+                    return 'rgvNo';
+                }
+                return type === 'rgv' ? 'deviceNo / rgvNo' : 'deviceNo / crnNo';
+            },
+            loadDeviceForm: function (type, value) {
+                this.resetDeviceForm();
+                if (!isDeviceConfigType(type)) {
+                    return;
+                }
+                var json = safeParseJson(value);
+                var valueKey = pickDeviceValueKey(type, json);
+                var deviceNo = '';
+                if (json && json[valueKey] != null) {
+                    deviceNo = String(json[valueKey]);
+                }
+                this.deviceForm = {
+                    valueKey: valueKey,
+                    deviceNo: deviceNo
+                };
+            },
+            isDevpDirectionActive: function (directionKey) {
+                return this.devpForm.direction.indexOf(directionKey) >= 0;
+            },
+            toggleDevpDirection: function (directionKey) {
+                if (!directionKey) {
+                    return;
+                }
+                var next = this.devpForm.direction.slice();
+                var index = next.indexOf(directionKey);
+                if (index >= 0) {
+                    next.splice(index, 1);
+                } else {
+                    next.push(directionKey);
+                }
+                this.devpForm.direction = DEVP_DIRECTION_OPTIONS.map(function (item) {
+                    return item.key;
+                }).filter(function (item) {
+                    return next.indexOf(item) >= 0;
+                });
+            },
+            applyCanvasSize: function () {
+                var self = this;
+                if (!this.doc) {
+                    return;
+                }
+                var width = toNumber(this.canvasForm.width, 0);
+                var height = toNumber(this.canvasForm.height, 0);
+                if (width <= 0 || height <= 0) {
+                    this.showMessage('warning', '鐢诲竷灏哄蹇呴』澶т簬 0');
+                    return;
+                }
+                var bounds = this.getElementBounds((this.doc.elements || []).map(function (item) {
+                    return item.id;
+                }));
+                if (bounds && (width < bounds.x + bounds.width || height < bounds.y + bounds.height)) {
+                    this.showMessage('warning', '鐢诲竷涓嶈兘灏忎簬褰撳墠鍏冪礌鍗犵敤鑼冨洿');
+                    return;
+                }
+                this.runMutation(function () {
+                    self.doc.canvasWidth = roundCoord(width);
+                    self.doc.canvasHeight = roundCoord(height);
+                });
+            },
+            applyGeometry: function () {
+                var self = this;
+                var element = this.singleSelectedElement;
+                if (!element) {
+                    return;
+                }
+                var next = {
+                    x: roundCoord(Math.max(0, toNumber(this.geometryForm.x, element.x))),
+                    y: roundCoord(Math.max(0, toNumber(this.geometryForm.y, element.y))),
+                    width: roundCoord(Math.max(MIN_ELEMENT_SIZE, toNumber(this.geometryForm.width, element.width))),
+                    height: roundCoord(Math.max(MIN_ELEMENT_SIZE, toNumber(this.geometryForm.height, element.height)))
+                };
+                if (!this.isWithinCanvas(next)) {
+                    this.showMessage('warning', '鍑犱綍灞炴�ц秴鍑哄綋鍓嶇敾甯冭寖鍥�');
+                    return;
+                }
+                var preview = deepClone(element);
+                preview.x = next.x;
+                preview.y = next.y;
+                preview.width = next.width;
+                preview.height = next.height;
+                if (this.hasOverlap(preview, [preview.id])) {
+                    this.showMessage('warning', '璋冩暣鍚庝細涓庡叾浠栧厓绱犻噸鍙�');
+                    return;
+                }
+                this.runMutation(function () {
+                    element.x = next.x;
+                    element.y = next.y;
+                    element.width = next.width;
+                    element.height = next.height;
+                });
+            },
+            applyRawValue: function () {
+                var self = this;
+                var element = this.singleSelectedElement;
+                if (!element || element.type === 'devp') {
+                    return;
+                }
+                this.runMutation(function () {
+                    element.value = normalizeValue(self.valueEditorText);
+                });
+            },
+            applyDeviceForm: function () {
+                var self = this;
+                var element = this.singleSelectedDeviceElement;
+                if (!element) {
+                    return;
+                }
+                var deviceNo = toInt(this.deviceForm.deviceNo, 0);
+                if (deviceNo <= 0) {
+                    this.showMessage('warning', '璁惧缂栧彿蹇呴』澶т簬 0');
+                    return;
+                }
+                var valueKey = this.deviceForm.valueKey || pickDeviceValueKey(element.type, safeParseJson(element.value));
+                this.runMutation(function () {
+                    var payload = safeParseJson(element.value) || {};
+                    delete payload.deviceNo;
+                    delete payload.crnNo;
+                    delete payload.rgvNo;
+                    payload[valueKey] = deviceNo;
+                    element.value = JSON.stringify(payload);
+                    self.valueEditorText = element.value;
+                });
+            },
+            applyDevpForm: function () {
+                var self = this;
+                var element = this.singleSelectedElement;
+                if (!element || element.type !== 'devp') {
+                    return;
+                }
+                var stationId = toInt(this.devpForm.stationId, 0);
+                var deviceNo = toInt(this.devpForm.deviceNo, 0);
+                if (stationId <= 0 || deviceNo <= 0) {
+                    this.showMessage('warning', '绔欏彿鍜� PLC 缂栧彿蹇呴』澶т簬 0');
+                    return;
+                }
+                var payload = {
+                    stationId: stationId,
+                    deviceNo: deviceNo
+                };
+                var directionList = normalizeDirectionList(this.devpForm.direction);
+                if (directionList.length > 0) {
+                    payload.direction = directionList;
+                }
+                var barcodeIdx = this.devpForm.barcodeIdx === '' ? 0 : toInt(this.devpForm.barcodeIdx, 0);
+                var backStation = this.devpForm.backStation === '' ? 0 : toInt(this.devpForm.backStation, 0);
+                var backStationDeviceNo = this.devpForm.backStationDeviceNo === '' ? 0 : toInt(this.devpForm.backStationDeviceNo, 0);
+                var barcodeStation = this.devpForm.barcodeStation === '' ? 0 : toInt(this.devpForm.barcodeStation, 0);
+                var barcodeStationDeviceNo = this.devpForm.barcodeStationDeviceNo === '' ? 0 : toInt(this.devpForm.barcodeStationDeviceNo, 0);
+                if (this.devpForm.isInStation && (barcodeStation <= 0 || barcodeStationDeviceNo <= 0)) {
+                    this.showMessage('warning', '鍏ョ珯鐐瑰繀椤诲~鍐欐潯鐮佺珯鍜屾潯鐮佺珯 PLC 缂栧彿');
+                    return;
+                }
+                if (this.devpForm.isBarcodeStation && (backStation <= 0 || backStationDeviceNo <= 0 || barcodeIdx <= 0)) {
+                    this.showMessage('warning', '鏉$爜绔欏繀椤诲~鍐欐潯鐮佺储寮曘�侀��鍥炵珯鍜岄��鍥炵珯 PLC 缂栧彿');
+                    return;
+                }
+                if (this.devpForm.isBarcodeStation) {
+                    payload.isBarcodeStation = 1;
+                }
+                if (barcodeIdx > 0) {
+                    payload.barcodeIdx = barcodeIdx;
+                }
+                if (backStation > 0) {
+                    payload.backStation = backStation;
+                }
+                if (backStationDeviceNo > 0) {
+                    payload.backStationDeviceNo = backStationDeviceNo;
+                }
+                if (this.devpForm.isInStation) {
+                    payload.isInStation = 1;
+                }
+                if (barcodeStation > 0) {
+                    payload.barcodeStation = barcodeStation;
+                }
+                if (barcodeStationDeviceNo > 0) {
+                    payload.barcodeStationDeviceNo = barcodeStationDeviceNo;
+                }
+                if (this.devpForm.isOutStation) {
+                    payload.isOutStation = 1;
+                }
+                if (this.devpForm.runBlockReassign) {
+                    payload.runBlockReassign = 1;
+                }
+                if (this.devpForm.isOutOrder) {
+                    payload.isOutOrder = 1;
+                }
+                if (this.devpForm.isLiftTransfer) {
+                    payload.isLiftTransfer = 1;
+                }
+                this.runMutation(function () {
+                    element.value = JSON.stringify(payload);
+                    self.valueEditorText = element.value;
+                });
+            },
+            deleteSelection: function () {
+                var self = this;
+                if (!this.doc || this.selectedIds.length === 0) {
+                    return;
+                }
+                var ids = this.selectedIds.slice();
+                this.runMutation(function () {
+                    self.doc.elements = self.doc.elements.filter(function (item) {
+                        return ids.indexOf(item.id) === -1;
+                    });
+                    self.selectedIds = [];
+                });
+            },
+            copySelection: function () {
+                var elements = this.getSelectedElements();
+                if (!elements.length) {
+                    return;
+                }
+                this.clipboard = deepClone(elements);
+                this.showMessage('success', '宸插鍒� ' + elements.length + ' 涓厓绱�');
+            },
+            getElementListBounds: function (elements) {
+                if (!elements || !elements.length) {
+                    return null;
+                }
+                var minX = elements[0].x;
+                var minY = elements[0].y;
+                var maxX = elements[0].x + elements[0].width;
+                var maxY = elements[0].y + elements[0].height;
+                for (var i = 1; i < elements.length; i++) {
+                    var element = elements[i];
+                    minX = Math.min(minX, element.x);
+                    minY = Math.min(minY, element.y);
+                    maxX = Math.max(maxX, element.x + element.width);
+                    maxY = Math.max(maxY, element.y + element.height);
+                }
+                return {
+                    x: minX,
+                    y: minY,
+                    width: maxX - minX,
+                    height: maxY - minY
+                };
+            },
+            getPasteTargetWorld: function () {
+                if (!this.doc) {
+                    return { x: 0, y: 0 };
+                }
+                var visible = this.getVisibleCanvasRect ? this.getVisibleCanvasRect() : this.getVisibleWorldRect();
+                var fallback = {
+                    x: visible.x + visible.width / 2,
+                    y: visible.y + visible.height / 2
+                };
+                if (!this.lastPointerWorld) {
+                    return fallback;
+                }
+                return {
+                    x: clamp(this.lastPointerWorld.x, 0, this.doc.canvasWidth),
+                    y: clamp(this.lastPointerWorld.y, 0, this.doc.canvasHeight),
+                    screenX: this.lastPointerWorld.screenX,
+                    screenY: this.lastPointerWorld.screenY
+                };
+            },
+            pasteClipboard: function () {
+                var self = this;
+                if (!this.doc || !this.clipboard.length) {
+                    return;
+                }
+                var sourceBounds = this.getElementListBounds(this.clipboard);
+                if (!sourceBounds) {
+                    return;
+                }
+                var target = this.getPasteTargetWorld();
+                var offsetX = target.x - (sourceBounds.x + sourceBounds.width / 2);
+                var offsetY = target.y - (sourceBounds.y + sourceBounds.height / 2);
+                var minOffsetX = -sourceBounds.x;
+                var maxOffsetX = this.doc.canvasWidth - (sourceBounds.x + sourceBounds.width);
+                var minOffsetY = -sourceBounds.y;
+                var maxOffsetY = this.doc.canvasHeight - (sourceBounds.y + sourceBounds.height);
+                offsetX = clamp(offsetX, minOffsetX, maxOffsetX);
+                offsetY = clamp(offsetY, minOffsetY, maxOffsetY);
+                var copies = deepClone(this.clipboard).map(function (item) {
+                    item.id = nextId();
+                    item.x = roundCoord(item.x + offsetX);
+                    item.y = roundCoord(item.y + offsetY);
+                    return item;
+                });
+                if (!this.canPlaceElements(copies, [])) {
+                    this.showMessage('warning', '绮樿创鍚庣殑鍏冪礌涓庣幇鏈夊厓绱犻噸鍙犳垨瓒呭嚭鐢诲竷');
+                    return;
+                }
+                this.runMutation(function () {
+                    self.doc.elements = self.doc.elements.concat(copies);
+                    self.selectedIds = copies.map(function (item) { return item.id; });
+                });
+            },
+            canArrayFromElement: function (element) {
+                return !!(element && ARRAY_TEMPLATE_TYPES.indexOf(element.type) >= 0);
+            },
+            getShelfFillSteps: function () {
+                return {
+                    row: this.shelfFillForm.rowStep === 'asc' ? 1 : -1,
+                    col: this.shelfFillForm.colStep === 'desc' ? -1 : 1
+                };
+            },
+            applyShelfSequenceToArrayCopies: function (template, copies) {
+                if (!template || template.type !== 'shelf' || !copies || !copies.length) {
+                    return copies;
+                }
+                var base = parseShelfLocationValue(template.value) || parseShelfLocationValue(this.shelfFillForm.startValue);
+                if (!base) {
+                    return copies;
+                }
+                var steps = this.getShelfFillSteps();
+                var horizontal = Math.abs(copies[0].x - template.x) >= Math.abs(copies[0].y - template.y);
+                var direction = 1;
+                if (horizontal) {
+                    direction = copies[0].x >= template.x ? 1 : -1;
+                } else {
+                    direction = copies[0].y >= template.y ? 1 : -1;
+                }
+                for (var i = 0; i < copies.length; i++) {
+                    var offset = i + 1;
+                    var row = base.row;
+                    var col = base.col;
+                    if (horizontal) {
+                        col = base.col + steps.col * direction * offset;
+                    } else {
+                        row = base.row + steps.row * direction * offset;
+                    }
+                    copies[i].value = formatShelfLocationValue(row, col);
+                }
+                return copies;
+            },
+            buildShelfGridAssignments: function (elements) {
+                if (!elements || !elements.length) {
+                    return null;
+                }
+                var clusterAxis = function (list, axis, sizeKey) {
+                    var sorted = list.map(function (item) {
+                        return {
+                            id: item.id,
+                            center: item[axis] + item[sizeKey] / 2,
+                            size: item[sizeKey]
+                        };
+                    }).sort(function (a, b) {
+                        return a.center - b.center;
+                    });
+                    var avgSize = sorted.reduce(function (sum, item) {
+                        return sum + item.size;
+                    }, 0) / sorted.length;
+                    var tolerance = Math.max(6, avgSize * 0.45);
+                    var groups = [];
+                    for (var i = 0; i < sorted.length; i++) {
+                        var current = sorted[i];
+                        var last = groups.length ? groups[groups.length - 1] : null;
+                        if (!last || Math.abs(current.center - last.center) > tolerance) {
+                            groups.push({
+                                center: current.center,
+                                items: [current]
+                            });
+                        } else {
+                            last.items.push(current);
+                            last.center = last.items.reduce(function (sum, item) {
+                                return sum + item.center;
+                            }, 0) / last.items.length;
+                        }
+                    }
+                    var indexById = {};
+                    for (var groupIndex = 0; groupIndex < groups.length; groupIndex++) {
+                        for (var itemIndex = 0; itemIndex < groups[groupIndex].items.length; itemIndex++) {
+                            indexById[groups[groupIndex].items[itemIndex].id] = groupIndex;
+                        }
+                    }
+                    return indexById;
+                };
+                return {
+                    rowById: clusterAxis(elements, 'y', 'height'),
+                    colById: clusterAxis(elements, 'x', 'width')
+                };
+            },
+            applyShelfAutoFill: function () {
+                var self = this;
+                var shelves = this.selectedShelfElements.slice();
+                if (!shelves.length) {
+                    this.showMessage('warning', '璇峰厛閫変腑鑷冲皯涓�涓揣鏋�');
+                    return;
+                }
+                var start = parseShelfLocationValue(this.shelfFillForm.startValue);
+                if (!start) {
+                    this.showMessage('warning', '璧峰鍊兼牸寮忓繀椤绘槸 鎺�-鍒楋紝渚嬪 12-1');
+                    return;
+                }
+                var grid = this.buildShelfGridAssignments(shelves);
+                if (!grid) {
+                    return;
+                }
+                var steps = this.getShelfFillSteps();
+                this.runMutation(function () {
+                    shelves.forEach(function (item) {
+                        var rowIndex = grid.rowById[item.id] || 0;
+                        var colIndex = grid.colById[item.id] || 0;
+                        item.value = formatShelfLocationValue(
+                            start.row + rowIndex * steps.row,
+                            start.col + colIndex * steps.col
+                        );
+                    });
+                    if (self.singleSelectedElement && self.singleSelectedElement.type === 'shelf') {
+                        self.valueEditorText = self.singleSelectedElement.value || '';
+                    }
+                });
+            },
+            buildArrayCopies: function (template, startWorld, currentWorld) {
+                if (!this.doc || !template || !startWorld || !currentWorld || !this.canArrayFromElement(template)) {
+                    return [];
+                }
+                var deltaX = currentWorld.x - startWorld.x;
+                var deltaY = currentWorld.y - startWorld.y;
+                if (Math.abs(deltaX) < COORD_EPSILON && Math.abs(deltaY) < COORD_EPSILON) {
+                    return [];
+                }
+                var horizontal = Math.abs(deltaX) >= Math.abs(deltaY);
+                var step = horizontal ? template.width : template.height;
+                if (step <= COORD_EPSILON) {
+                    return [];
+                }
+                var direction = (horizontal ? deltaX : deltaY) >= 0 ? 1 : -1;
+                var distance;
+                if (horizontal) {
+                    distance = direction > 0
+                        ? currentWorld.x - (template.x + template.width)
+                        : template.x - currentWorld.x;
+                } else {
+                    distance = direction > 0
+                        ? currentWorld.y - (template.y + template.height)
+                        : template.y - currentWorld.y;
+                }
+                var count = Math.max(0, Math.floor((distance + step * 0.5) / step));
+                if (count <= 0) {
+                    return [];
+                }
+                var copies = [];
+                for (var i = 1; i <= count; i++) {
+                    copies.push({
+                        type: template.type,
+                        x: roundCoord(template.x + (horizontal ? direction * template.width * i : 0)),
+                        y: roundCoord(template.y + (horizontal ? 0 : direction * template.height * i)),
+                        width: template.width,
+                        height: template.height,
+                        value: template.value
+                    });
+                }
+                return this.applyShelfSequenceToArrayCopies(template, copies);
+            },
+            duplicateSelection: function () {
+                this.copySelection();
+                this.pasteClipboard();
+            },
+            getElementBounds: function (ids) {
+                if (!this.doc) {
+                    return null;
+                }
+                var elements = ids && ids.length ? this.getSelectedElements() : (this.doc.elements || []);
+                if (ids && ids.length) {
+                    elements = ids.map(function (id) {
+                        return this.findElementById(id);
+                    }, this).filter(Boolean);
+                }
+                if (!elements.length) {
+                    return null;
+                }
+                var minX = elements[0].x;
+                var minY = elements[0].y;
+                var maxX = elements[0].x + elements[0].width;
+                var maxY = elements[0].y + elements[0].height;
+                for (var i = 1; i < elements.length; i++) {
+                    var element = elements[i];
+                    minX = Math.min(minX, element.x);
+                    minY = Math.min(minY, element.y);
+                    maxX = Math.max(maxX, element.x + element.width);
+                    maxY = Math.max(maxY, element.y + element.height);
+                }
+                return {
+                    x: minX,
+                    y: minY,
+                    width: maxX - minX,
+                    height: maxY - minY
+                };
+            },
+            fitContent: function () {
+                if (!this.doc || !this.pixiApp) {
+                    return;
+                }
+                var contentBounds = this.getElementBounds();
+                if (contentBounds && contentBounds.width > 0 && contentBounds.height > 0) {
+                    this.fitRect(contentBounds, this.pixiApp.renderer.width, this.pixiApp.renderer.height);
+                    return;
+                }
+                this.fitCanvas();
+            },
+            fitCanvas: function () {
+                if (!this.doc || !this.pixiApp) {
+                    return;
+                }
+                var renderer = this.pixiApp.renderer;
+                var target = {
+                    x: 0,
+                    y: 0,
+                    width: Math.max(1, this.doc.canvasWidth),
+                    height: Math.max(1, this.doc.canvasHeight)
+                };
+                this.fitRect(target, renderer.width, renderer.height);
+            },
+            fitSelection: function () {
+                if (!this.selectedIds.length || !this.pixiApp) {
+                    return;
+                }
+                var bounds = this.getElementBounds(this.selectedIds);
+                if (!bounds) {
+                    return;
+                }
+                this.fitRect(bounds, this.pixiApp.renderer.width, this.pixiApp.renderer.height);
+            },
+            fitRect: function (rect, viewportWidth, viewportHeight) {
+                var padding = 80;
+                var scale = Math.min(
+                    (viewportWidth - padding * 2) / Math.max(rect.width, 1),
+                    (viewportHeight - padding * 2) / Math.max(rect.height, 1)
+                );
+                scale = clamp(scale, 0.06, 4);
+                this.camera.scale = scale;
+                this.camera.x = Math.round((viewportWidth - rect.width * scale) / 2 - rect.x * scale);
+                this.camera.y = Math.round((viewportHeight - rect.height * scale) / 2 - rect.y * scale);
+                this.viewZoom = scale;
+                this.markGridSceneDirty();
+                this.markStaticSceneDirty();
+                this.scheduleRender();
+            },
+            resetView: function () {
+                this.fitCanvas();
+            },
+            getVisibleWorldRect: function () {
+                if (!this.pixiApp) {
+                    return {
+                        x: 0,
+                        y: 0,
+                        width: 0,
+                        height: 0
+                    };
+                }
+                return {
+                    x: (-this.camera.x) / this.camera.scale,
+                    y: (-this.camera.y) / this.camera.scale,
+                    width: this.pixiApp.renderer.width / this.camera.scale,
+                    height: this.pixiApp.renderer.height / this.camera.scale
+                };
+            },
+            getVisibleCanvasRect: function () {
+                if (!this.doc) {
+                    return {
+                        x: 0,
+                        y: 0,
+                        width: 0,
+                        height: 0
+                    };
+                }
+                var visible = this.getVisibleWorldRect();
+                var left = clamp(visible.x, 0, this.doc.canvasWidth);
+                var top = clamp(visible.y, 0, this.doc.canvasHeight);
+                var right = clamp(visible.x + visible.width, 0, this.doc.canvasWidth);
+                var bottom = clamp(visible.y + visible.height, 0, this.doc.canvasHeight);
+                return {
+                    x: left,
+                    y: top,
+                    width: Math.max(0, right - left),
+                    height: Math.max(0, bottom - top)
+                };
+            },
+            getWorldRectWithPadding: function (screenPadding) {
+                if (!this.doc) {
+                    return {
+                        x: 0,
+                        y: 0,
+                        width: 0,
+                        height: 0
+                    };
+                }
+                var visible = this.getVisibleWorldRect();
+                var padding = Math.max(screenPadding / this.camera.scale, 24);
+                var left = Math.max(0, visible.x - padding);
+                var top = Math.max(0, visible.y - padding);
+                var right = Math.min(this.doc.canvasWidth, visible.x + visible.width + padding);
+                var bottom = Math.min(this.doc.canvasHeight, visible.y + visible.height + padding);
+                return {
+                    x: left,
+                    y: top,
+                    width: Math.max(0, right - left),
+                    height: Math.max(0, bottom - top)
+                };
+            },
+            worldRectContains: function (outer, inner) {
+                if (!outer || !inner) {
+                    return false;
+                }
+                return inner.x >= outer.x - COORD_EPSILON
+                    && inner.y >= outer.y - COORD_EPSILON
+                    && inner.x + inner.width <= outer.x + outer.width + COORD_EPSILON
+                    && inner.y + inner.height <= outer.y + outer.height + COORD_EPSILON;
+            },
+            getGridRenderKey: function () {
+                var minorStep = this.camera.scale > 1.5 ? 50 : (this.camera.scale > 0.45 ? 100 : 200);
+                return minorStep + '|' + (Math.round(this.camera.scale * 8) / 8);
+            },
+            getStaticRenderKey: function () {
+                return (this.camera.scale >= 0.85 ? 'round' : 'flat') + '|' + (Math.round(this.camera.scale * 8) / 8);
+            },
+            scheduleRender: function () {
+                if (this.renderQueued) {
+                    return;
+                }
+                this.renderQueued = true;
+                window.requestAnimationFrame(function () {
+                    this.renderQueued = false;
+                    this.renderScene();
+                }.bind(this));
+            },
+            renderScene: function () {
+                if (!this.pixiApp || !this.doc) {
+                    return;
+                }
+                this.mapRoot.position.set(this.camera.x, this.camera.y);
+                this.mapRoot.scale.set(this.camera.scale, this.camera.scale);
+                this.viewZoom = this.camera.scale;
+                var visible = this.getVisibleCanvasRect();
+                var viewportSettled = !this.isZooming && !this.isPanning && !(this.interactionState && this.interactionState.type === 'pan');
+                var gridKeyChanged = this.gridRenderKey !== this.getGridRenderKey();
+                if (this.gridSceneDirty || !this.gridRenderRect || (viewportSettled && gridKeyChanged) || (viewportSettled && !this.worldRectContains(this.gridRenderRect, visible))) {
+                    this.renderGrid(this.getWorldRectWithPadding(STATIC_VIEW_PADDING));
+                    this.gridSceneDirty = false;
+                }
+                var excludedKey = this.selectionKey(this.getStaticExcludedIds());
+                var staticKeyChanged = this.staticRenderKey !== this.getStaticRenderKey();
+                if (this.staticSceneDirty || !this.staticRenderRect || (viewportSettled && staticKeyChanged)
+                    || this.staticExcludedKey !== excludedKey || (viewportSettled && !this.worldRectContains(this.staticRenderRect, visible))) {
+                    this.renderStaticElements(this.getWorldRectWithPadding(STATIC_VIEW_PADDING), excludedKey);
+                    this.staticSceneDirty = false;
+                }
+                this.renderActiveElements();
+                this.renderLabels();
+                this.renderHover();
+                this.renderSelection();
+                this.renderGuide();
+                this.updateCursor();
+            },
+            getStaticExcludedIds: function () {
+                if (!this.interactionState) {
+                    return [];
+                }
+                if (this.interactionState.type === 'move' && this.selectedIds.length) {
+                    return this.selectedIds.slice();
+                }
+                if (this.interactionState.type === 'resize' && this.interactionState.elementId) {
+                    return [this.interactionState.elementId];
+                }
+                return [];
+            },
+            getRenderableElements: function (excludeIds, renderRect) {
+                if (!this.doc) {
+                    return [];
+                }
+                var rect = renderRect || this.getWorldRectWithPadding(STATIC_VIEW_PADDING);
+                var candidates = this.querySpatialCandidates(rect, 0, excludeIds);
+                var result = [];
+                for (var i = 0; i < candidates.length; i++) {
+                    if (rectIntersects(rect, candidates[i])) {
+                        result.push(candidates[i]);
+                    }
+                }
+                return result;
+            },
+            renderGrid: function (renderRect) {
+                if (!this.gridLayer || !this.doc) {
+                    return;
+                }
+                var visible = renderRect || this.getVisibleWorldRect();
+                var width = this.doc.canvasWidth;
+                var height = this.doc.canvasHeight;
+                var minorStep = this.camera.scale > 1.5 ? 50 : (this.camera.scale > 0.45 ? 100 : 200);
+                var majorStep = minorStep * 5;
+                var lineWidth = 1 / this.camera.scale;
+                var xStart = Math.max(0, Math.floor(visible.x / minorStep) * minorStep);
+                var yStart = Math.max(0, Math.floor(visible.y / minorStep) * minorStep);
+                var xEnd = Math.min(width, visible.x + visible.width);
+                var yEnd = Math.min(height, visible.y + visible.height);
+
+                this.gridLayer.clear();
+                this.gridLayer.beginFill(0xfafcff, 1);
+                this.gridLayer.drawRect(0, 0, width, height);
+                this.gridLayer.endFill();
+
+                this.gridLayer.lineStyle(lineWidth, 0xdbe4ee, 1);
+                this.gridLayer.drawRect(0, 0, width, height);
+
+                for (var x = xStart; x <= xEnd; x += minorStep) {
+                    var colorX = (x % majorStep === 0) ? 0xc9d7e6 : 0xe4ebf3;
+                    this.gridLayer.lineStyle(lineWidth, colorX, x % majorStep === 0 ? 0.95 : 0.75);
+                    this.gridLayer.moveTo(x, 0);
+                    this.gridLayer.lineTo(x, height);
+                }
+                for (var y = yStart; y <= yEnd; y += minorStep) {
+                    var colorY = (y % majorStep === 0) ? 0xc9d7e6 : 0xe4ebf3;
+                    this.gridLayer.lineStyle(lineWidth, colorY, y % majorStep === 0 ? 0.95 : 0.75);
+                    this.gridLayer.moveTo(0, y);
+                    this.gridLayer.lineTo(width, y);
+                }
+                this.gridRenderRect = {
+                    x: visible.x,
+                    y: visible.y,
+                    width: visible.width,
+                    height: visible.height
+                };
+                this.gridRenderKey = this.getGridRenderKey();
+            },
+            drawGridPatch: function (rects, layer) {
+                if (!this.doc || !layer || !rects || !rects.length) {
+                    return;
+                }
+                var width = this.doc.canvasWidth;
+                var height = this.doc.canvasHeight;
+                var minorStep = this.camera.scale > 1.5 ? 50 : (this.camera.scale > 0.45 ? 100 : 200);
+                var majorStep = minorStep * 5;
+                var lineWidth = 1 / this.camera.scale;
+                for (var i = 0; i < rects.length; i++) {
+                    var rect = rects[i];
+                    var left = clamp(rect.x - lineWidth, 0, width);
+                    var top = clamp(rect.y - lineWidth, 0, height);
+                    var right = clamp(rect.x + rect.width + lineWidth, 0, width);
+                    var bottom = clamp(rect.y + rect.height + lineWidth, 0, height);
+                    if (right <= left || bottom <= top) {
+                        continue;
+                    }
+                    layer.lineStyle(0, 0, 0, 0);
+                    layer.beginFill(0xfafcff, 1);
+                    layer.drawRect(left, top, right - left, bottom - top);
+                    layer.endFill();
+                    if (right - left < minorStep || bottom - top < minorStep) {
+                        continue;
+                    }
+                    var xStart = Math.floor(left / minorStep) * minorStep;
+                    var yStart = Math.floor(top / minorStep) * minorStep;
+                    for (var x = xStart; x <= right; x += minorStep) {
+                        if (x < left || x > right) {
+                            continue;
+                        }
+                        var colorX = (x % majorStep === 0) ? 0xc9d7e6 : 0xe4ebf3;
+                        layer.lineStyle(lineWidth, colorX, x % majorStep === 0 ? 0.95 : 0.75);
+                        layer.moveTo(x, top);
+                        layer.lineTo(x, bottom);
+                    }
+                    for (var y = yStart; y <= bottom; y += minorStep) {
+                        if (y < top || y > bottom) {
+                            continue;
+                        }
+                        var colorY = (y % majorStep === 0) ? 0xc9d7e6 : 0xe4ebf3;
+                        layer.lineStyle(lineWidth, colorY, y % majorStep === 0 ? 0.95 : 0.75);
+                        layer.moveTo(left, y);
+                        layer.lineTo(right, y);
+                    }
+                }
+            },
+            drawPatchObjects: function (rects, excludeIds) {
+                if (!rects || !rects.length || !this.patchObjectLayer) {
+                    return;
+                }
+                var seen = {};
+                var elements = [];
+                for (var i = 0; i < rects.length; i++) {
+                    var candidates = this.querySpatialCandidates(rects[i], 0, excludeIds);
+                    for (var j = 0; j < candidates.length; j++) {
+                        var item = candidates[j];
+                        if (!seen[item.id] && rectIntersects(rects[i], item)) {
+                            seen[item.id] = true;
+                            elements.push(item);
+                        }
+                    }
+                }
+                if (!elements.length) {
+                    return;
+                }
+                this.drawElementsToLayers(elements, this.patchObjectLayer, this.patchObjectLayer);
+            },
+            drawElementsToLayers: function (elements, trackLayer, nodeLayer) {
+                var lineWidth = 1 / this.camera.scale;
+                var useRounded = this.camera.scale >= 0.85;
+                var radius = Math.max(6 / this.camera.scale, 2);
+                var buckets = {};
+                for (var i = 0; i < elements.length; i++) {
+                    var element = elements[i];
+                    var bucketKey = (element.type === 'shelf' ? 'node' : 'track') + ':' + element.type;
+                    if (!buckets[bucketKey]) {
+                        buckets[bucketKey] = [];
+                    }
+                    buckets[bucketKey].push(element);
+                }
+                for (var bucketKey in buckets) {
+                    if (!buckets.hasOwnProperty(bucketKey)) {
+                        continue;
+                    }
+                    var parts = bucketKey.split(':');
+                    var type = parts[1];
+                    var meta = getTypeMeta(type);
+                    var layer = parts[0] === 'node' ? nodeLayer : trackLayer;
+                    layer.lineStyle(lineWidth, meta.border, 1);
+                    layer.beginFill(meta.fill, 0.92);
+                    var bucket = buckets[bucketKey];
+                    for (var j = 0; j < bucket.length; j++) {
+                        var item = bucket[j];
+                        if (useRounded) {
+                            layer.drawRoundedRect(item.x, item.y, item.width, item.height, radius);
+                        } else {
+                            layer.drawRect(item.x, item.y, item.width, item.height);
+                        }
+                    }
+                    layer.endFill();
+                }
+            },
+            ensureStaticSprite: function (poolName, index) {
+                var pool = poolName === 'node' ? this.staticNodeSpritePool : this.staticTrackSpritePool;
+                var layer = poolName === 'node' ? this.staticNodeSpriteLayer : this.staticTrackSpriteLayer;
+                if (pool[index]) {
+                    return pool[index];
+                }
+                var sprite = new PIXI.Sprite(PIXI.Texture.WHITE);
+                sprite.position.set(0, 0);
+                sprite.anchor.set(0, 0);
+                sprite.visible = false;
+                sprite.alpha = 0;
+                layer.addChild(sprite);
+                pool[index] = sprite;
+                return sprite;
+            },
+            hideUnusedStaticSprites: function (pool, fromIndex) {
+                for (var i = fromIndex; i < pool.length; i++) {
+                    pool[i].visible = false;
+                    pool[i].alpha = 0;
+                    pool[i].width = 0;
+                    pool[i].height = 0;
+                    pool[i].position.set(-99999, -99999);
+                }
+            },
+            pruneStaticSpritePool: function (poolName, keepCount, slack) {
+                var pool = poolName === 'node' ? this.staticNodeSpritePool : this.staticTrackSpritePool;
+                var layer = poolName === 'node' ? this.staticNodeSpriteLayer : this.staticTrackSpriteLayer;
+                var target = Math.max(0, keepCount + Math.max(0, slack || 0));
+                if (!pool || !layer || pool.length <= target) {
+                    return;
+                }
+                for (var i = pool.length - 1; i >= target; i--) {
+                    var sprite = pool[i];
+                    layer.removeChild(sprite);
+                    if (sprite && sprite.destroy) {
+                        sprite.destroy();
+                    }
+                    pool.pop();
+                }
+            },
+            drawElementsToSpriteLayers: function (elements) {
+                var trackCount = 0;
+                var nodeCount = 0;
+                for (var i = 0; i < elements.length; i++) {
+                    var item = elements[i];
+                    var meta = getTypeMeta(item.type);
+                    var poolName = item.type === 'shelf' ? 'node' : 'track';
+                    var sprite = this.ensureStaticSprite(poolName, poolName === 'node' ? nodeCount : trackCount);
+                    sprite.visible = true;
+                    sprite.position.set(item.x, item.y);
+                    sprite.width = item.width;
+                    sprite.height = item.height;
+                    sprite.tint = meta.fill;
+                    sprite.alpha = 0.92;
+                    if (poolName === 'node') {
+                        nodeCount += 1;
+                    } else {
+                        trackCount += 1;
+                    }
+                }
+                this.hideUnusedStaticSprites(this.staticTrackSpritePool, trackCount);
+                this.hideUnusedStaticSprites(this.staticNodeSpritePool, nodeCount);
+                if (this.camera.scale < DENSE_SIMPLIFY_SCALE_THRESHOLD) {
+                    this.pruneStaticSpritePool('track', trackCount, STATIC_SPRITE_POOL_SLACK);
+                    this.pruneStaticSpritePool('node', nodeCount, STATIC_SPRITE_POOL_SLACK);
+                }
+            },
+            simplifyRenderableElements: function (elements) {
+                if (!elements || elements.length < 2) {
+                    return elements || [];
+                }
+                var sorted = elements.slice().sort(function (a, b) {
+                    if (a.type !== b.type) {
+                        return a.type < b.type ? -1 : 1;
+                    }
+                    if (Math.abs(a.y - b.y) > COORD_EPSILON) {
+                        return a.y - b.y;
+                    }
+                    if (Math.abs(a.height - b.height) > COORD_EPSILON) {
+                        return a.height - b.height;
+                    }
+                    return a.x - b.x;
+                });
+                var result = [];
+                var current = null;
+                for (var i = 0; i < sorted.length; i++) {
+                    var item = sorted[i];
+                    if (!current) {
+                        current = {
+                            type: item.type,
+                            x: item.x,
+                            y: item.y,
+                            width: item.width,
+                            height: item.height
+                        };
+                        continue;
+                    }
+                    var currentRight = current.x + current.width;
+                    var itemRight = item.x + item.width;
+                    var sameBand = current.type === item.type
+                        && Math.abs(current.y - item.y) <= 0.5
+                        && Math.abs(current.height - item.height) <= 0.5;
+                    var joinable = item.x <= currentRight + 0.5;
+                    if (sameBand && joinable) {
+                        current.width = roundCoord(Math.max(currentRight, itemRight) - current.x);
+                    } else {
+                        result.push(current);
+                        current = {
+                            type: item.type,
+                            x: item.x,
+                            y: item.y,
+                            width: item.width,
+                            height: item.height
+                        };
+                    }
+                }
+                if (current) {
+                    result.push(current);
+                }
+                return result;
+            },
+            renderStaticElements: function (renderRect, excludedKey) {
+                if (!this.doc) {
+                    return;
+                }
+                this.trackLayer.clear();
+                this.nodeLayer.clear();
+                this.eraseLayer.clear();
+                this.patchObjectLayer.clear();
+                var renderableElements = this.getRenderableElements(this.getStaticExcludedIds(), renderRect);
+                var useSpriteMode = this.camera.scale < STATIC_SPRITE_SCALE_THRESHOLD;
+                var shouldSimplify = this.camera.scale < STATIC_SIMPLIFY_SCALE_THRESHOLD
+                    || (this.camera.scale < DENSE_SIMPLIFY_SCALE_THRESHOLD && renderableElements.length > DENSE_SIMPLIFY_ELEMENT_THRESHOLD);
+                this.staticTrackSpriteLayer.visible = useSpriteMode;
+                this.staticNodeSpriteLayer.visible = useSpriteMode;
+                this.trackLayer.visible = !useSpriteMode;
+                this.nodeLayer.visible = !useSpriteMode;
+                if (useSpriteMode) {
+                    if (shouldSimplify) {
+                        renderableElements = this.simplifyRenderableElements(renderableElements);
+                    }
+                    this.drawElementsToSpriteLayers(renderableElements);
+                } else {
+                    this.hideUnusedStaticSprites(this.staticTrackSpritePool, 0);
+                    this.hideUnusedStaticSprites(this.staticNodeSpritePool, 0);
+                    this.drawElementsToLayers(renderableElements, this.trackLayer, this.nodeLayer);
+                }
+                var rect = renderRect || this.getWorldRectWithPadding(STATIC_VIEW_PADDING);
+                this.staticRenderRect = {
+                    x: rect.x,
+                    y: rect.y,
+                    width: rect.width,
+                    height: rect.height
+                };
+                this.staticRenderKey = this.getStaticRenderKey();
+                this.staticExcludedKey = excludedKey != null ? excludedKey : this.selectionKey(this.getStaticExcludedIds());
+                this.pendingStaticCommit = null;
+            },
+            renderActiveElements: function () {
+                this.activeLayer.clear();
+                this.eraseLayer.clear();
+                this.patchObjectLayer.clear();
+                var activeIds = this.getStaticExcludedIds();
+                if (!activeIds.length) {
+                    return;
+                }
+                var activeElements = [];
+                for (var idx = 0; idx < activeIds.length; idx++) {
+                    var element = this.findElementById(activeIds[idx]);
+                    if (element) {
+                        activeElements.push(element);
+                    }
+                }
+                if (!activeElements.length) {
+                    return;
+                }
+                this.drawElementsToLayers(activeElements, this.activeLayer, this.activeLayer);
+            },
+            getLabelText: function (element) {
+                var meta = getTypeMeta(element.type);
+                var value = safeParseJson(element.value);
+                if (element.type === 'devp' && value) {
+                    var station = value.stationId != null ? String(value.stationId) : '';
+                    var arrows = formatDirectionArrows(value.direction);
+                    if (station && arrows) {
+                        return element.height > element.width * 1.15 ? (station + '\n' + arrows) : (station + ' ' + arrows);
+                    }
+                    if (station) {
+                        return station;
+                    }
+                    if (arrows) {
+                        return arrows;
+                    }
+                    return meta.shortLabel;
+                }
+                if ((element.type === 'crn' || element.type === 'dualCrn' || element.type === 'rgv') && value) {
+                    if (value.deviceNo != null) {
+                        return meta.shortLabel + ' ' + value.deviceNo;
+                    }
+                    if (value.crnNo != null) {
+                        return meta.shortLabel + ' ' + value.crnNo;
+                    }
+                    if (value.rgvNo != null) {
+                        return meta.shortLabel + ' ' + value.rgvNo;
+                    }
+                }
+                if (element.value && element.value.length <= 18 && element.value.indexOf('{') !== 0) {
+                    return element.value;
+                }
+                return meta.shortLabel;
+            },
+            ensureLabelSprite: function (index) {
+                if (this.labelPool[index]) {
+                    return this.labelPool[index];
+                }
+                var label = new PIXI.Text('', {
+                    fontFamily: 'Avenir Next, PingFang SC, Microsoft YaHei, sans-serif',
+                    fontSize: 12,
+                    fontWeight: '600',
+                    fill: 0x223448,
+                    align: 'center'
+                });
+                label.anchor.set(0.5);
+                this.labelLayer.addChild(label);
+                this.labelPool[index] = label;
+                return label;
+            },
+            getLabelRenderBudget: function () {
+                if (!this.pixiApp || !this.pixiApp.renderer) {
+                    return MIN_LABEL_COUNT;
+                }
+                var renderer = this.pixiApp.renderer;
+                var viewportArea = renderer.width * renderer.height;
+                return clamp(Math.round(viewportArea / 12000), MIN_LABEL_COUNT, MAX_LABEL_COUNT);
+            },
+            getLabelMinScreenWidth: function (text) {
+                var lines = String(text || '').split('\n');
+                var length = 0;
+                for (var i = 0; i < lines.length; i++) {
+                    length = Math.max(length, String(lines[i] || '').trim().length);
+                }
+                if (length <= 4) {
+                    return 26;
+                }
+                if (length <= 8) {
+                    return 40;
+                }
+                if (length <= 12) {
+                    return 52;
+                }
+                return 64;
+            },
+            getLabelMinScreenHeight: function (text) {
+                var lines = String(text || '').split('\n');
+                var length = 0;
+                for (var i = 0; i < lines.length; i++) {
+                    length = Math.max(length, String(lines[i] || '').trim().length);
+                }
+                var lineHeight = length <= 4 ? 14 : 18;
+                return lineHeight * Math.max(lines.length, 1);
+            },
+            renderLabels: function () {
+                if (!this.doc) {
+                    return;
+                }
+                var capability = this.ensureLabelCapability();
+                if (capability.maxWidth * this.camera.scale < ABS_MIN_LABEL_SCREEN_WIDTH
+                    || capability.maxHeight * this.camera.scale < ABS_MIN_LABEL_SCREEN_HEIGHT) {
+                    this.labelLayer.visible = false;
+                    return;
+                }
+                if (this.isZooming || this.isPanning || this.camera.scale < MIN_LABEL_SCALE
+                    || (this.interactionState && (this.interactionState.type === 'move' || this.interactionState.type === 'resize' || this.interactionState.type === 'pan'))) {
+                    this.labelLayer.visible = false;
+                    return;
+                }
+                this.labelLayer.visible = true;
+                var visible = this.getVisibleWorldRect();
+                var elements = this.querySpatialCandidates(visible, 0, []);
+                if (elements.length > DENSE_LABEL_HIDE_ELEMENT_THRESHOLD && this.camera.scale < DENSE_LABEL_HIDE_SCALE_THRESHOLD) {
+                    this.labelLayer.visible = false;
+                    return;
+                }
+                var hasRoomForAnyLabel = false;
+                for (var roomIdx = 0; roomIdx < elements.length; roomIdx++) {
+                    var candidate = elements[roomIdx];
+                    if (candidate.width * this.camera.scale >= ABS_MIN_LABEL_SCREEN_WIDTH
+                        && candidate.height * this.camera.scale >= ABS_MIN_LABEL_SCREEN_HEIGHT) {
+                        hasRoomForAnyLabel = true;
+                        break;
+                    }
+                }
+                if (!hasRoomForAnyLabel) {
+                    this.labelLayer.visible = false;
+                    return;
+                }
+                var visibleElements = [];
+                for (var i = 0; i < elements.length; i++) {
+                    var element = elements[i];
+                    var text = this.getLabelText(element);
+                    if (!text) {
+                        continue;
+                    }
+                    if (!rectIntersects(visible, element)) {
+                        continue;
+                    }
+                    if (element.width * this.camera.scale < this.getLabelMinScreenWidth(text) || element.height * this.camera.scale < this.getLabelMinScreenHeight(text)) {
+                        continue;
+                    }
+                    visibleElements.push({
+                        element: element,
+                        text: text
+                    });
+                }
+                visibleElements.sort(function (a, b) {
+                    return (b.element.width * b.element.height) - (a.element.width * a.element.height);
+                });
+                var labelBudget = this.getLabelRenderBudget();
+                if (visibleElements.length > labelBudget) {
+                    visibleElements = visibleElements.slice(0, labelBudget);
+                }
+                for (var j = 0; j < visibleElements.length; j++) {
+                    var item = visibleElements[j].element;
+                    var label = this.ensureLabelSprite(j);
+                    label.visible = true;
+                    label.text = visibleElements[j].text;
+                    label.position.set(item.x + item.width / 2, item.y + item.height / 2);
+                    label.scale.set(1 / this.camera.scale, 1 / this.camera.scale);
+                    label.alpha = this.selectedIds.indexOf(item.id) >= 0 ? 1 : 0.88;
+                }
+                for (var k = visibleElements.length; k < this.labelPool.length; k++) {
+                    this.labelPool[k].visible = false;
+                }
+            },
+            renderHover: function () {
+                this.hoverLayer.clear();
+                if (this.interactionState || !this.hoverElementId || this.selectedIds.indexOf(this.hoverElementId) >= 0) {
+                    return;
+                }
+                var element = this.findElementById(this.hoverElementId);
+                if (!element) {
+                    return;
+                }
+                var lineWidth = 2 / this.camera.scale;
+                this.hoverLayer.lineStyle(lineWidth, 0x2f79d6, 0.95);
+                this.hoverLayer.drawRoundedRect(element.x, element.y, element.width, element.height, Math.max(6 / this.camera.scale, 2));
+            },
+            renderSelection: function () {
+                this.selectionLayer.clear();
+                if (!this.selectedIds.length || (this.interactionState && (this.interactionState.type === 'move' || this.interactionState.type === 'resize'))) {
+                    return;
+                }
+                var elements = this.getSelectedElements();
+                var lineWidth = 2 / this.camera.scale;
+                for (var i = 0; i < elements.length; i++) {
+                    var element = elements[i];
+                    this.selectionLayer.lineStyle(lineWidth, 0x2568b8, 1);
+                    this.selectionLayer.beginFill(0x2f79d6, 0.07);
+                    this.selectionLayer.drawRoundedRect(element.x, element.y, element.width, element.height, Math.max(6 / this.camera.scale, 2));
+                    this.selectionLayer.endFill();
+                }
+                if (elements.length !== 1) {
+                    return;
+                }
+                var handleSize = HANDLE_SCREEN_SIZE / this.camera.scale;
+                var handlePositions = this.getHandlePositions(elements[0]);
+                this.selectionLayer.lineStyle(1 / this.camera.scale, 0x1d5ea9, 1);
+                this.selectionLayer.beginFill(0xffffff, 1);
+                for (var key in handlePositions) {
+                    if (!handlePositions.hasOwnProperty(key)) {
+                        continue;
+                    }
+                    var pos = handlePositions[key];
+                    this.selectionLayer.drawRect(pos.x - handleSize / 2, pos.y - handleSize / 2, handleSize, handleSize);
+                }
+                this.selectionLayer.endFill();
+            },
+            renderGuide: function () {
+                this.guideLayer.clear();
+                if (this.guideText) {
+                    this.guideText.visible = false;
+                }
+                if (!this.interactionState) {
+                    return;
+                }
+                var state = this.interactionState;
+                if (state.type === 'draw' && state.rect && state.rect.width > 0 && state.rect.height > 0) {
+                    var drawMeta = getTypeMeta(state.elementType);
+                    this.guideLayer.lineStyle(2 / this.camera.scale, drawMeta.border, 0.95);
+                    this.guideLayer.beginFill(drawMeta.fill, 0.18);
+                    this.guideLayer.drawRoundedRect(state.rect.x, state.rect.y, state.rect.width, state.rect.height, Math.max(6 / this.camera.scale, 2));
+                    this.guideLayer.endFill();
+                    return;
+                }
+                if (state.type === 'array' && state.template) {
+                    var previewItems = state.previewItems || [];
+                    var arrayMeta = getTypeMeta(state.template.type);
+                    var lineWidth = 2 / this.camera.scale;
+                    var templateCenterX = state.template.x + state.template.width / 2;
+                    var templateCenterY = state.template.y + state.template.height / 2;
+                    this.guideLayer.lineStyle(lineWidth, arrayMeta.border, 0.9);
+                    this.guideLayer.moveTo(templateCenterX, templateCenterY);
+                    this.guideLayer.lineTo(state.currentWorld.x, state.currentWorld.y);
+                    if (!previewItems.length) {
+                        return;
+                    }
+                    this.guideLayer.lineStyle(1 / this.camera.scale, arrayMeta.border, 0.8);
+                    this.guideLayer.beginFill(arrayMeta.fill, 0.2);
+                    for (var previewIndex = 0; previewIndex < previewItems.length; previewIndex++) {
+                        var preview = previewItems[previewIndex];
+                        this.guideLayer.drawRoundedRect(preview.x, preview.y, preview.width, preview.height, Math.max(6 / this.camera.scale, 2));
+                    }
+                    this.guideLayer.endFill();
+                    if (this.guideText) {
+                        this.guideText.text = '灏嗙敓鎴� ' + previewItems.length + ' 涓�';
+                        this.guideText.position.set(state.currentWorld.x, state.currentWorld.y - 10 / this.camera.scale);
+                        this.guideText.scale.set(1 / this.camera.scale);
+                        this.guideText.visible = true;
+                    }
+                    return;
+                }
+                if (state.type === 'marquee') {
+                    var rect = buildRectFromPoints(state.startWorld, state.currentWorld);
+                    if (rect.width <= 0 || rect.height <= 0) {
+                        return;
+                    }
+                    this.guideLayer.lineStyle(2 / this.camera.scale, 0x2f79d6, 0.92);
+                    this.guideLayer.beginFill(0x2f79d6, 0.06);
+                    this.guideLayer.drawRect(rect.x, rect.y, rect.width, rect.height);
+                    this.guideLayer.endFill();
+                }
+            },
+            pointerToWorld: function (event) {
+                var rect = this.pixiApp.view.getBoundingClientRect();
+                var screenX = event.clientX - rect.left;
+                var screenY = event.clientY - rect.top;
+                return {
+                    screenX: screenX,
+                    screenY: screenY,
+                    x: roundCoord((screenX - this.camera.x) / this.camera.scale),
+                    y: roundCoord((screenY - this.camera.y) / this.camera.scale)
+                };
+            },
+            isWithinCanvas: function (rect) {
+                if (!this.doc) {
+                    return false;
+                }
+                return rect.x >= -COORD_EPSILON && rect.y >= -COORD_EPSILON
+                    && rect.x + rect.width <= this.doc.canvasWidth + COORD_EPSILON
+                    && rect.y + rect.height <= this.doc.canvasHeight + COORD_EPSILON;
+            },
+            canPlaceElements: function (elements, excludeIds) {
+                excludeIds = excludeIds || [];
+                for (var i = 0; i < elements.length; i++) {
+                    if (!this.isWithinCanvas(elements[i])) {
+                        return false;
+                    }
+                    if (this.hasOverlap(elements[i], excludeIds.concat([elements[i].id]))) {
+                        return false;
+                    }
+                }
+                return true;
+            },
+            hasOverlap: function (candidate, excludeIds) {
+                if (!this.doc) {
+                    return false;
+                }
+                var elements = this.querySpatialCandidates(candidate, COORD_EPSILON, excludeIds);
+                for (var i = 0; i < elements.length; i++) {
+                    var item = elements[i];
+                    if (rectsOverlap(candidate, item)) {
+                        return true;
+                    }
+                }
+                return false;
+            },
+            snapToleranceWorld: function () {
+                return Math.max(1, EDGE_SNAP_SCREEN_TOLERANCE / this.camera.scale);
+            },
+            collectMoveSnap: function (baseItems, dx, dy, excludeIds) {
+                if (!this.doc || !baseItems || !baseItems.length) {
+                    return { dx: 0, dy: 0 };
+                }
+                var tolerance = this.snapToleranceWorld();
+                var bestDx = null;
+                var bestDy = null;
+                for (var i = 0; i < baseItems.length; i++) {
+                    var moving = baseItems[i];
+                    var movedLeft = moving.x + dx;
+                    var movedRight = movedLeft + moving.width;
+                    var movedTop = moving.y + dy;
+                    var movedBottom = movedTop + moving.height;
+                    var candidates = this.querySpatialCandidates({
+                        x: movedLeft,
+                        y: movedTop,
+                        width: moving.width,
+                        height: moving.height
+                    }, tolerance, excludeIds);
+                    for (var j = 0; j < candidates.length; j++) {
+                        var other = candidates[j];
+                        var otherLeft = other.x;
+                        var otherRight = other.x + other.width;
+                        var otherTop = other.y;
+                        var otherBottom = other.y + other.height;
+                        if (rangesNearOrOverlap(movedTop, movedBottom, otherTop, otherBottom, tolerance)) {
+                            var horizontalCandidates = [
+                                otherLeft - movedRight,
+                                otherRight - movedLeft,
+                                otherLeft - movedLeft,
+                                otherRight - movedRight
+                            ];
+                            for (var hx = 0; hx < horizontalCandidates.length; hx++) {
+                                var deltaX = horizontalCandidates[hx];
+                                if (Math.abs(deltaX) <= tolerance && (bestDx === null || Math.abs(deltaX) < Math.abs(bestDx))) {
+                                    bestDx = deltaX;
+                                }
+                            }
+                        }
+                        if (rangesNearOrOverlap(movedLeft, movedRight, otherLeft, otherRight, tolerance)) {
+                            var verticalCandidates = [
+                                otherTop - movedBottom,
+                                otherBottom - movedTop,
+                                otherTop - movedTop,
+                                otherBottom - movedBottom
+                            ];
+                            for (var vy = 0; vy < verticalCandidates.length; vy++) {
+                                var deltaY = verticalCandidates[vy];
+                                if (Math.abs(deltaY) <= tolerance && (bestDy === null || Math.abs(deltaY) < Math.abs(bestDy))) {
+                                    bestDy = deltaY;
+                                }
+                            }
+                        }
+                    }
+                }
+                return {
+                    dx: bestDx == null ? 0 : bestDx,
+                    dy: bestDy == null ? 0 : bestDy
+                };
+            },
+            collectResizeSnap: function (rect, handle, excludeIds) {
+                if (!this.doc || !rect) {
+                    return null;
+                }
+                var tolerance = this.snapToleranceWorld();
+                var left = rect.x;
+                var right = rect.x + rect.width;
+                var top = rect.y;
+                var bottom = rect.y + rect.height;
+                var bestLeft = null;
+                var bestRight = null;
+                var bestTop = null;
+                var bestBottom = null;
+                function pickBest(current, candidate) {
+                    if (candidate == null) {
+                        return current;
+                    }
+                    if (current == null || Math.abs(candidate) < Math.abs(current)) {
+                        return candidate;
+                    }
+                    return current;
+                }
+                if (handle.indexOf('w') >= 0) {
+                    bestLeft = pickBest(bestLeft, -left);
+                }
+                if (handle.indexOf('e') >= 0) {
+                    bestRight = pickBest(bestRight, this.doc.canvasWidth - right);
+                }
+                if (handle.indexOf('n') >= 0) {
+                    bestTop = pickBest(bestTop, -top);
+                }
+                if (handle.indexOf('s') >= 0) {
+                    bestBottom = pickBest(bestBottom, this.doc.canvasHeight - bottom);
+                }
+                var elements = this.querySpatialCandidates(rect, tolerance, excludeIds);
+                for (var i = 0; i < elements.length; i++) {
+                    var other = elements[i];
+                    var otherLeft = other.x;
+                    var otherRight = other.x + other.width;
+                    var otherTop = other.y;
+                    var otherBottom = other.y + other.height;
+                    if (rangesNearOrOverlap(top, bottom, otherTop, otherBottom, tolerance)) {
+                        if (handle.indexOf('w') >= 0) {
+                            bestLeft = pickBest(bestLeft, otherLeft - left);
+                            bestLeft = pickBest(bestLeft, otherRight - left);
+                        }
+                        if (handle.indexOf('e') >= 0) {
+                            bestRight = pickBest(bestRight, otherLeft - right);
+                            bestRight = pickBest(bestRight, otherRight - right);
+                        }
+                    }
+                    if (rangesNearOrOverlap(left, right, otherLeft, otherRight, tolerance)) {
+                        if (handle.indexOf('n') >= 0) {
+                            bestTop = pickBest(bestTop, otherTop - top);
+                            bestTop = pickBest(bestTop, otherBottom - top);
+                        }
+                        if (handle.indexOf('s') >= 0) {
+                            bestBottom = pickBest(bestBottom, otherTop - bottom);
+                            bestBottom = pickBest(bestBottom, otherBottom - bottom);
+                        }
+                    }
+                }
+                if (bestLeft != null && Math.abs(bestLeft) > tolerance) {
+                    bestLeft = null;
+                }
+                if (bestRight != null && Math.abs(bestRight) > tolerance) {
+                    bestRight = null;
+                }
+                if (bestTop != null && Math.abs(bestTop) > tolerance) {
+                    bestTop = null;
+                }
+                if (bestBottom != null && Math.abs(bestBottom) > tolerance) {
+                    bestBottom = null;
+                }
+                return {
+                    left: bestLeft,
+                    right: bestRight,
+                    top: bestTop,
+                    bottom: bestBottom
+                };
+            },
+            hitTestElement: function (point) {
+                if (!this.doc) {
+                    return null;
+                }
+                var candidates = this.querySpatialCandidates({
+                    x: point.x,
+                    y: point.y,
+                    width: 0,
+                    height: 0
+                }, 0, []);
+                if (!candidates.length) {
+                    return null;
+                }
+                var candidateMap = {};
+                for (var c = 0; c < candidates.length; c++) {
+                    candidateMap[candidates[c].id] = true;
+                }
+                var elements = this.doc.elements || [];
+                for (var i = elements.length - 1; i >= 0; i--) {
+                    var element = elements[i];
+                    if (!candidateMap[element.id]) {
+                        continue;
+                    }
+                    if (point.x >= element.x && point.x <= element.x + element.width
+                        && point.y >= element.y && point.y <= element.y + element.height) {
+                        return element;
+                    }
+                }
+                return null;
+            },
+            getHandlePositions: function (element) {
+                var x = element.x;
+                var y = element.y;
+                var w = element.width;
+                var h = element.height;
+                var cx = x + w / 2;
+                var cy = y + h / 2;
+                return {
+                    nw: { x: x, y: y },
+                    n: { x: cx, y: y },
+                    ne: { x: x + w, y: y },
+                    e: { x: x + w, y: cy },
+                    se: { x: x + w, y: y + h },
+                    s: { x: cx, y: y + h },
+                    sw: { x: x, y: y + h },
+                    w: { x: x, y: cy }
+                };
+            },
+            getResizeHandleAt: function (point, element) {
+                var handlePositions = this.getHandlePositions(element);
+                var baseTolerance = HANDLE_SCREEN_SIZE / this.camera.scale;
+                var sizeLimitedTolerance = Math.max(Math.min(element.width, element.height) / 4, 3 / this.camera.scale);
+                var tolerance = Math.min(baseTolerance, sizeLimitedTolerance);
+                var bestHandle = '';
+                var bestDistance = Infinity;
+                for (var key in handlePositions) {
+                    if (!handlePositions.hasOwnProperty(key)) {
+                        continue;
+                    }
+                    var pos = handlePositions[key];
+                    var dx = Math.abs(point.x - pos.x);
+                    var dy = Math.abs(point.y - pos.y);
+                    if (dx <= tolerance && dy <= tolerance) {
+                        var distance = dx + dy;
+                        if (distance < bestDistance) {
+                            bestDistance = distance;
+                            bestHandle = key;
+                        }
+                    }
+                }
+                return bestHandle;
+            },
+            cursorForHandle: function (handle) {
+                if (handle === 'nw' || handle === 'se') {
+                    return 'nwse-resize';
+                }
+                if (handle === 'ne' || handle === 'sw') {
+                    return 'nesw-resize';
+                }
+                if (handle === 'n' || handle === 's') {
+                    return 'ns-resize';
+                }
+                if (handle === 'e' || handle === 'w') {
+                    return 'ew-resize';
+                }
+                return 'default';
+            },
+            updateCursor: function () {
+                if (!this.pixiApp) {
+                    return;
+                }
+                var cursor = 'default';
+                if (this.interactionState) {
+                    if (this.interactionState.type === 'pan') {
+                        cursor = 'grabbing';
+                    } else if (this.interactionState.type === 'draw' || this.interactionState.type === 'marquee') {
+                        cursor = 'crosshair';
+                    } else if (this.interactionState.type === 'array') {
+                        cursor = 'crosshair';
+                    } else if (this.interactionState.type === 'move') {
+                        cursor = 'move';
+                    } else if (this.interactionState.type === 'movePending') {
+                        cursor = 'grab';
+                    } else if (this.interactionState.type === 'resize') {
+                        cursor = this.cursorForHandle(this.interactionState.handle);
+                    }
+                } else if (this.spacePressed || this.activeTool === 'pan') {
+                    cursor = 'grab';
+                } else if (DRAW_TYPES.indexOf(this.activeTool) >= 0 || this.activeTool === 'marquee' || this.activeTool === 'array') {
+                    cursor = 'crosshair';
+                } else if (this.singleSelectedElement) {
+                    var point = this.lastPointerWorld || null;
+                    if (point) {
+                        var handle = this.getResizeHandleAt(point, this.singleSelectedElement);
+                        cursor = handle ? this.cursorForHandle(handle) : 'default';
+                    }
+                    if (cursor === 'default' && this.hoverElementId) {
+                        cursor = 'move';
+                    } else if (cursor === 'default') {
+                        cursor = 'grab';
+                    }
+                } else {
+                    cursor = this.hoverElementId ? 'move' : 'grab';
+                }
+                if (cursor !== this.lastCursor) {
+                    this.lastCursor = cursor;
+                    this.pixiApp.view.style.cursor = cursor;
+                }
+            },
+            startPan: function (point) {
+                this.cancelDeferredStaticRebuild();
+                this.cancelPanRefresh();
+                if (this.zoomRefreshTimer) {
+                    window.clearTimeout(this.zoomRefreshTimer);
+                    this.zoomRefreshTimer = null;
+                    this.isZooming = false;
+                    this.pendingViewportRefresh = true;
+                }
+                this.isPanning = true;
+                this.interactionState = {
+                    type: 'pan',
+                    startScreen: {
+                        x: point.screenX,
+                        y: point.screenY
+                    },
+                    startCamera: {
+                        x: this.camera.x,
+                        y: this.camera.y
+                    }
+                };
+                this.updateCursor();
+            },
+            startMarquee: function (point, additive) {
+                this.cancelDeferredStaticRebuild();
+                this.interactionState = {
+                    type: 'marquee',
+                    additive: !!additive,
+                    startWorld: { x: point.x, y: point.y },
+                    currentWorld: { x: point.x, y: point.y }
+                };
+                this.updateCursor();
+            },
+            startDraw: function (point) {
+                this.cancelDeferredStaticRebuild();
+                this.interactionState = {
+                    type: 'draw',
+                    beforeSnapshot: this.snapshotDoc(this.doc),
+                    elementType: this.activeTool,
+                    startWorld: { x: point.x, y: point.y },
+                    rect: { x: point.x, y: point.y, width: 0, height: 0 }
+                };
+                this.updateCursor();
+            },
+            startArray: function (point, element) {
+                if (!this.canArrayFromElement(element)) {
+                    this.showMessage('warning', '闃靛垪宸ュ叿褰撳墠鍙敮鎸佽揣鏋躲�丆RN銆佸弻宸ヤ綅鍜� RGV');
+                    return;
+                }
+                this.cancelDeferredStaticRebuild();
+                this.interactionState = {
+                    type: 'array',
+                    beforeSnapshot: this.snapshotDoc(this.doc),
+                    template: {
+                        id: element.id,
+                        type: element.type,
+                        x: element.x,
+                        y: element.y,
+                        width: element.width,
+                        height: element.height,
+                        value: element.value
+                    },
+                    startWorld: { x: point.x, y: point.y },
+                    currentWorld: { x: point.x, y: point.y },
+                    previewItems: []
+                };
+                this.updateCursor();
+            },
+            startMove: function (point) {
+                var selected = this.getSelectedElements();
+                if (!selected.length) {
+                    return;
+                }
+                this.cancelDeferredStaticRebuild();
+                var baseItems = selected.map(function (item) {
+                    return {
+                        id: item.id,
+                        x: item.x,
+                        y: item.y,
+                        width: item.width,
+                        height: item.height,
+                        value: item.value,
+                        type: item.type
+                    };
+                });
+                this.interactionState = {
+                    type: 'movePending',
+                    beforeSnapshot: this.snapshotDoc(this.doc),
+                    startScreen: { x: point.screenX, y: point.screenY },
+                    startWorld: { x: point.x, y: point.y },
+                    baseItems: baseItems
+                };
+                this.updateCursor();
+            },
+            startResize: function (point, element, handle) {
+                this.cancelDeferredStaticRebuild();
+                this.interactionState = {
+                    type: 'resize',
+                    handle: handle,
+                    elementId: element.id,
+                    beforeSnapshot: this.snapshotDoc(this.doc),
+                    baseRect: {
+                        x: element.x,
+                        y: element.y,
+                        width: element.width,
+                        height: element.height
+                    }
+                };
+                this.markStaticSceneDirty();
+                this.scheduleRender();
+                this.updateCursor();
+            },
+            onCanvasPointerDown: function (event) {
+                if (!this.doc || !this.pixiApp) {
+                    return;
+                }
+                if (event.button !== 0 && event.button !== 1) {
+                    return;
+                }
+                if (this.pixiApp.view.setPointerCapture && event.pointerId != null) {
+                    try {
+                        this.pixiApp.view.setPointerCapture(event.pointerId);
+                    } catch (ignore) {
+                    }
+                }
+                this.currentPointerId = event.pointerId;
+                var point = this.pointerToWorld(event);
+                this.lastPointerWorld = point;
+                this.pointerStatus = this.formatNumber(point.x) + ', ' + this.formatNumber(point.y);
+                if (this.spacePressed || this.activeTool === 'pan' || event.button === 1) {
+                    this.startPan(point);
+                    return;
+                }
+                if (DRAW_TYPES.indexOf(this.activeTool) >= 0) {
+                    this.startDraw(point);
+                    return;
+                }
+                if (this.activeTool === 'marquee') {
+                    this.startMarquee(point, event.shiftKey);
+                    return;
+                }
+                if (this.activeTool === 'array') {
+                    var arrayHit = this.hitTestElement(point);
+                    var arrayTemplate = arrayHit || this.singleSelectedElement;
+                    if (arrayHit && this.selectedIds.indexOf(arrayHit.id) < 0) {
+                        this.setSelectedIds([arrayHit.id]);
+                        arrayTemplate = arrayHit;
+                    }
+                    if (!arrayTemplate) {
+                        this.showMessage('warning', '璇峰厛閫変腑涓�涓揣鏋舵垨杞ㄩ亾浣滀负闃靛垪妯℃澘');
+                        return;
+                    }
+                    this.startArray(point, arrayTemplate);
+                    return;
+                }
+
+                var selected = this.singleSelectedElement;
+                var handle = selected ? this.getResizeHandleAt(point, selected) : '';
+                if (handle) {
+                    this.startResize(point, selected, handle);
+                    return;
+                }
+
+                var hit = this.hitTestElement(point);
+                if (hit) {
+                    if (event.shiftKey) {
+                        var index = this.selectedIds.indexOf(hit.id);
+                        if (index >= 0) {
+                            var nextIds = this.selectedIds.slice();
+                            nextIds.splice(index, 1);
+                            this.setSelectedIds(nextIds);
+                        } else {
+                            this.setSelectedIds(this.selectedIds.concat([hit.id]));
+                        }
+                        this.scheduleRender();
+                        return;
+                    }
+                    if (this.selectedIds.indexOf(hit.id) < 0) {
+                        this.setSelectedIds([hit.id]);
+                        this.scheduleRender();
+                    }
+                    this.startMove(point);
+                    return;
+                }
+
+                if (this.selectedIds.length) {
+                    this.setSelectedIds([]);
+                    this.scheduleRender();
+                }
+                this.startPan(point);
+            },
+            onCanvasWheel: function (event) {
+                if (!this.pixiApp || !this.doc) {
+                    return;
+                }
+                event.preventDefault();
+                var point = this.pointerToWorld(event);
+                var delta = event.deltaY < 0 ? 1.12 : 0.89;
+                var nextScale = clamp(this.camera.scale * delta, 0.06, 4);
+                this.camera.scale = nextScale;
+                this.camera.x = Math.round(point.screenX - point.x * nextScale);
+                this.camera.y = Math.round(point.screenY - point.y * nextScale);
+                this.viewZoom = nextScale;
+                this.scheduleZoomRefresh();
+                this.scheduleRender();
+            },
+            onWindowPointerMove: function (event) {
+                if (!this.pixiApp || !this.doc) {
+                    return;
+                }
+                var point = this.pointerToWorld(event);
+                this.lastPointerWorld = point;
+                var pointerText = this.formatNumber(point.x) + ', ' + this.formatNumber(point.y);
+                var now = (window.performance && performance.now) ? performance.now() : Date.now();
+                if (pointerText !== this.pointerStatus && (now - this.lastPointerStatusUpdateTs >= POINTER_STATUS_UPDATE_INTERVAL || this.pointerStatus === '--')) {
+                    this.pointerStatus = pointerText;
+                    this.lastPointerStatusUpdateTs = now;
+                }
+                if (!this.interactionState) {
+                    var hover = this.hitTestElement(point);
+                    var hoverId = hover ? hover.id : '';
+                    if (hoverId !== this.hoverElementId) {
+                        this.hoverElementId = hoverId;
+                        this.scheduleRender();
+                    }
+                    this.updateCursor();
+                    return;
+                }
+
+                var state = this.interactionState;
+                if (state.type === 'pan') {
+                    this.camera.x = Math.round(state.startCamera.x + (point.screenX - state.startScreen.x));
+                    this.camera.y = Math.round(state.startCamera.y + (point.screenY - state.startScreen.y));
+                    this.scheduleRender();
+                    return;
+                }
+
+                if (state.type === 'marquee') {
+                    state.currentWorld = { x: point.x, y: point.y };
+                    this.scheduleRender();
+                    return;
+                }
+
+                if (state.type === 'draw') {
+                    var rawRect = buildRectFromPoints(state.startWorld, point);
+                    var clipped = {
+                        x: clamp(rawRect.x, 0, this.doc.canvasWidth),
+                        y: clamp(rawRect.y, 0, this.doc.canvasHeight),
+                        width: clamp(rawRect.width, 0, this.doc.canvasWidth),
+                        height: clamp(rawRect.height, 0, this.doc.canvasHeight)
+                    };
+                    if (clipped.x + clipped.width > this.doc.canvasWidth) {
+                        clipped.width = roundCoord(this.doc.canvasWidth - clipped.x);
+                    }
+                    if (clipped.y + clipped.height > this.doc.canvasHeight) {
+                        clipped.height = roundCoord(this.doc.canvasHeight - clipped.y);
+                    }
+                    state.rect = clipped;
+                    this.scheduleRender();
+                    return;
+                }
+                if (state.type === 'array') {
+                    state.currentWorld = { x: point.x, y: point.y };
+                    state.previewItems = this.buildArrayCopies(state.template, state.startWorld, state.currentWorld);
+                    this.scheduleRender();
+                    return;
+                }
+
+                if (state.type === 'movePending') {
+                    var dragDistance = Math.max(Math.abs(point.screenX - state.startScreen.x), Math.abs(point.screenY - state.startScreen.y));
+                    if (dragDistance < DRAG_START_THRESHOLD) {
+                        return;
+                    }
+                    state.type = 'move';
+                    this.markStaticSceneDirty();
+                    this.scheduleRender();
+                    this.updateCursor();
+                }
+
+                if (state.type === 'move') {
+                    var dx = point.x - state.startWorld.x;
+                    var dy = point.y - state.startWorld.y;
+                    var minDx = -Infinity;
+                    var maxDx = Infinity;
+                    var minDy = -Infinity;
+                    var maxDy = Infinity;
+                    for (var i = 0; i < state.baseItems.length; i++) {
+                        var base = state.baseItems[i];
+                        minDx = Math.max(minDx, -base.x);
+                        minDy = Math.max(minDy, -base.y);
+                        maxDx = Math.min(maxDx, this.doc.canvasWidth - (base.x + base.width));
+                        maxDy = Math.min(maxDy, this.doc.canvasHeight - (base.y + base.height));
+                    }
+                    dx = clamp(dx, minDx, maxDx);
+                    dy = clamp(dy, minDy, maxDy);
+                    var snapDelta = this.collectMoveSnap(state.baseItems, dx, dy, this.selectedIds.slice());
+                    dx = clamp(dx + snapDelta.dx, minDx, maxDx);
+                    dy = clamp(dy + snapDelta.dy, minDy, maxDy);
+                    for (var j = 0; j < state.baseItems.length; j++) {
+                        var baseItem = state.baseItems[j];
+                        var element = this.findElementById(baseItem.id);
+                        if (!element) {
+                            continue;
+                        }
+                        element.x = roundCoord(baseItem.x + dx);
+                        element.y = roundCoord(baseItem.y + dy);
+                    }
+                    this.scheduleRender();
+                    return;
+                }
+
+                if (state.type === 'resize') {
+                    var target = this.findElementById(state.elementId);
+                    if (!target) {
+                        return;
+                    }
+                    var baseRect = state.baseRect;
+                    var left = baseRect.x;
+                    var right = baseRect.x + baseRect.width;
+                    var top = baseRect.y;
+                    var bottom = baseRect.y + baseRect.height;
+                    if (state.handle.indexOf('w') >= 0) {
+                        left = clamp(point.x, 0, right - MIN_ELEMENT_SIZE);
+                    }
+                    if (state.handle.indexOf('e') >= 0) {
+                        right = clamp(point.x, left + MIN_ELEMENT_SIZE, this.doc.canvasWidth);
+                    }
+                    if (state.handle.indexOf('n') >= 0) {
+                        top = clamp(point.y, 0, bottom - MIN_ELEMENT_SIZE);
+                    }
+                    if (state.handle.indexOf('s') >= 0) {
+                        bottom = clamp(point.y, top + MIN_ELEMENT_SIZE, this.doc.canvasHeight);
+                    }
+                    var snapped = this.collectResizeSnap({
+                        x: left,
+                        y: top,
+                        width: right - left,
+                        height: bottom - top
+                    }, state.handle, [target.id]);
+                    if (snapped) {
+                        if (state.handle.indexOf('w') >= 0 && snapped.left != null) {
+                            left = clamp(left + snapped.left, 0, right - MIN_ELEMENT_SIZE);
+                        }
+                        if (state.handle.indexOf('e') >= 0 && snapped.right != null) {
+                            right = clamp(right + snapped.right, left + MIN_ELEMENT_SIZE, this.doc.canvasWidth);
+                        }
+                        if (state.handle.indexOf('n') >= 0 && snapped.top != null) {
+                            top = clamp(top + snapped.top, 0, bottom - MIN_ELEMENT_SIZE);
+                        }
+                        if (state.handle.indexOf('s') >= 0 && snapped.bottom != null) {
+                            bottom = clamp(bottom + snapped.bottom, top + MIN_ELEMENT_SIZE, this.doc.canvasHeight);
+                        }
+                    }
+                    target.x = roundCoord(left);
+                    target.y = roundCoord(top);
+                    target.width = roundCoord(right - left);
+                    target.height = roundCoord(bottom - top);
+                    this.scheduleRender();
+                }
+            },
+            onWindowPointerUp: function (event) {
+                if (!this.interactionState) {
+                    return;
+                }
+                if (this.currentPointerId != null && event.pointerId != null && this.currentPointerId !== event.pointerId) {
+                    return;
+                }
+                if (this.pixiApp && this.pixiApp.view.releasePointerCapture && event.pointerId != null) {
+                    try {
+                        this.pixiApp.view.releasePointerCapture(event.pointerId);
+                    } catch (ignore) {
+                    }
+                }
+                this.currentPointerId = null;
+
+                var state = this.interactionState;
+                this.interactionState = null;
+
+                if (state.type === 'pan') {
+                    this.updateCursor();
+                    this.schedulePanRefresh();
+                    this.scheduleRender();
+                    return;
+                }
+
+                if (state.type === 'marquee') {
+                    var rect = buildRectFromPoints(state.startWorld, state.currentWorld);
+                    if (rect.width > 2 && rect.height > 2) {
+                        var matched = (this.doc.elements || []).filter(function (item) {
+                            return rectIntersects(rect, item);
+                        }).map(function (item) {
+                            return item.id;
+                        });
+                        this.setSelectedIds(state.additive ? Array.from(new Set(this.selectedIds.concat(matched))) : matched);
+                    }
+                    this.scheduleRender();
+                    return;
+                }
+
+                if (state.type === 'movePending') {
+                    this.updateCursor();
+                    return;
+                }
+
+                if (state.type === 'draw') {
+                    var drawRect = state.rect;
+                    if (drawRect && drawRect.width >= MIN_ELEMENT_SIZE && drawRect.height >= MIN_ELEMENT_SIZE) {
+                        var newElement = {
+                            id: nextId(),
+                            type: state.elementType,
+                            x: roundCoord(drawRect.x),
+                            y: roundCoord(drawRect.y),
+                            width: roundCoord(drawRect.width),
+                            height: roundCoord(drawRect.height),
+                            value: ''
+                        };
+                        if (this.hasOverlap(newElement, [])) {
+                            this.showMessage('warning', '鏂板厓绱犱笉鑳戒笌宸叉湁鍏冪礌閲嶅彔');
+                        } else if (!this.isWithinCanvas(newElement)) {
+                            this.showMessage('warning', '鏂板厓绱犺秴鍑虹敾甯冭寖鍥�');
+                        } else {
+                            this.doc.elements.push(newElement);
+                            this.selectedIds = [newElement.id];
+                            this.commitMutation(state.beforeSnapshot);
+                            this.refreshInspector();
+                            return;
+                        }
+                    }
+                    this.refreshInspector();
+                    this.scheduleRender();
+                    return;
+                }
+                if (state.type === 'array') {
+                    var copies = state.previewItems && state.previewItems.length
+                        ? state.previewItems
+                        : this.buildArrayCopies(state.template, state.startWorld, state.currentWorld || state.startWorld);
+                    if (!copies.length) {
+                        this.scheduleRender();
+                        return;
+                    }
+                    if (!this.canPlaceElements(copies, [])) {
+                        this.showMessage('warning', '闃靛垪鐢熸垚鍚庝細閲嶅彔鎴栬秴鍑虹敾甯冿紝宸插彇娑�');
+                        this.scheduleRender();
+                        return;
+                    }
+                    var finalizedCopies = copies.map(function (item) {
+                        return $.extend({}, item, { id: nextId() });
+                    });
+                    var self = this;
+                    this.runMutation(function () {
+                        self.doc.elements = self.doc.elements.concat(finalizedCopies);
+                        self.selectedIds = [finalizedCopies[finalizedCopies.length - 1].id];
+                    });
+                    return;
+                }
+
+                if (state.type === 'move') {
+                    var movedElements = this.getSelectedElements();
+                    if (!this.canPlaceElements(movedElements, this.selectedIds.slice())) {
+                        for (var i = 0; i < state.baseItems.length; i++) {
+                            var base = state.baseItems[i];
+                            var element = this.findElementById(base.id);
+                            if (!element) {
+                                continue;
+                            }
+                            element.x = base.x;
+                            element.y = base.y;
+                        }
+                        this.showMessage('warning', '绉诲姩鍚庝細閲嶅彔鎴栬秴鍑虹敾甯冿紝宸叉仮澶�');
+                        this.refreshInspector();
+                        this.scheduleRender();
+                        return;
+                    }
+                    if (!this.commitMutation(state.beforeSnapshot)) {
+                        this.markStaticSceneDirty();
+                        this.scheduleRender();
+                    }
+                    return;
+                }
+
+                if (state.type === 'resize') {
+                    var resized = this.findElementById(state.elementId);
+                    if (resized) {
+                        if (!this.isWithinCanvas(resized) || this.hasOverlap(resized, [resized.id])) {
+                            resized.x = state.baseRect.x;
+                            resized.y = state.baseRect.y;
+                            resized.width = state.baseRect.width;
+                            resized.height = state.baseRect.height;
+                            this.showMessage('warning', '缂╂斁鍚庝細閲嶅彔鎴栬秴鍑虹敾甯冿紝宸叉仮澶�');
+                            this.refreshInspector();
+                            this.scheduleRender();
+                            return;
+                        }
+                    }
+                    if (!this.commitMutation(state.beforeSnapshot)) {
+                        this.markStaticSceneDirty();
+                        this.scheduleRender();
+                    }
+                    return;
+                }
+
+                this.scheduleRender();
+            },
+            onWindowKeyDown: function (event) {
+                if (event.key === ' ' && !isInputLike(event.target)) {
+                    this.spacePressed = true;
+                    this.updateCursor();
+                    event.preventDefault();
+                }
+                if (!this.doc) {
+                    return;
+                }
+                if (isInputLike(event.target)) {
+                    return;
+                }
+                var ctrl = event.ctrlKey || event.metaKey;
+                if (event.key === 'Delete' || event.key === 'Backspace') {
+                    event.preventDefault();
+                    this.deleteSelection();
+                    return;
+                }
+                if (ctrl && (event.key === 'z' || event.key === 'Z')) {
+                    event.preventDefault();
+                    if (event.shiftKey) {
+                        this.redo();
+                    } else {
+                        this.undo();
+                    }
+                    return;
+                }
+                if (ctrl && (event.key === 'y' || event.key === 'Y')) {
+                    event.preventDefault();
+                    this.redo();
+                    return;
+                }
+                if (ctrl && (event.key === 'c' || event.key === 'C')) {
+                    event.preventDefault();
+                    this.copySelection();
+                    return;
+                }
+                if (ctrl && (event.key === 'v' || event.key === 'V')) {
+                    event.preventDefault();
+                    this.pasteClipboard();
+                    return;
+                }
+                if (event.key === 'Escape') {
+                    this.interactionState = null;
+                    this.setSelectedIds([]);
+                    this.hoverElementId = '';
+                    this.scheduleRender();
+                }
+            },
+            onWindowKeyUp: function (event) {
+                if (event.key === ' ') {
+                    this.spacePressed = false;
+                    this.updateCursor();
+                }
+            },
+            onBeforeUnload: function (event) {
+                if (!this.isDirty) {
+                    return;
+                }
+                event.preventDefault();
+                event.returnValue = '';
+            }
+        }
+    });
+})();
diff --git a/src/main/webapp/views/basMap/basMap.html b/src/main/webapp/views/basMap/basMap.html
index c001131..f152002 100644
--- a/src/main/webapp/views/basMap/basMap.html
+++ b/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>
diff --git a/src/main/webapp/views/basMap/editor.html b/src/main/webapp/views/basMap/editor.html
new file mode 100644
index 0000000..4e182b9
--- /dev/null
+++ b/src/main/webapp/views/basMap/editor.html
@@ -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>姝e湪鍔犺浇 {{ 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>
diff --git a/src/main/webapp/views/watch/console.html b/src/main/webapp/views/watch/console.html
index d2adab1..284561b 100644
--- a/src/main/webapp/views/watch/console.html
+++ b/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({
diff --git a/src/main/webapp/views/watch/fakeTrace.html b/src/main/webapp/views/watch/fakeTrace.html
index ecf2c92..2110046 100644
--- a/src/main/webapp/views/watch/fakeTrace.html
+++ b/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;
 

--
Gitblit v1.9.1