#
Junjie
8 小时以前 e9b531edd2917b01a80dfa14e917ec21ddad8882
#
9个文件已添加
8个文件已修改
2342 ■■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/ConsoleController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/vo/StationTaskTraceEventVo.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/vo/StationTaskTraceSegmentVo.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/vo/StationTaskTraceVo.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/ws/ConsoleWebSocket.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/ServerBootstrap.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/model/command/StationCommand.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/network/ZyStationConnectDriver.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/network/fake/ZyStationFakeSegConnect.java 9 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZyStationV5Thread.java 312 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/v5/StationV5SegmentExecutionPlan.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/v5/StationV5SegmentExecutor.java 347 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/v5/StationV5SegmentPlanner.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/trace/StationTaskTraceRegistry.java 517 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/DevpCard.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MapCanvas.js 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/stationTrace.html 903 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/ConsoleController.java
@@ -16,6 +16,7 @@
import com.zy.asrs.domain.vo.FakeTaskTraceVo;
import com.zy.asrs.domain.vo.StationLatestDataVo;
import com.zy.asrs.domain.vo.RgvLatestDataVo;
import com.zy.asrs.domain.vo.StationTaskTraceVo;
import com.zy.asrs.entity.*;
import com.zy.asrs.service.*;
import com.zy.common.CodeRes;
@@ -32,6 +33,7 @@
import com.zy.core.thread.RgvThread;
import com.zy.core.model.protocol.RgvProtocol;
import com.zy.core.network.fake.FakeTaskTraceRegistry;
import com.zy.core.trace.StationTaskTraceRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -65,6 +67,8 @@
    private StationCycleCapacityService stationCycleCapacityService;
    @Autowired
    private FakeTaskTraceRegistry fakeTaskTraceRegistry;
    @Autowired
    private StationTaskTraceRegistry stationTaskTraceRegistry;
    @PostMapping("/system/running/status")
    @ManagerAuth(memo = "系统运行状态")
@@ -285,6 +289,13 @@
        return R.ok().add(traceList);
    }
    @PostMapping("/latest/data/station/trace")
    @ManagerAuth(memo = "输送任务轨迹实时数据")
    public R stationTaskTraceLatestData() {
        List<StationTaskTraceVo> traceList = stationTaskTraceRegistry.listLatestTraces();
        return R.ok().add(traceList);
    }
    // @PostMapping("/latest/data/barcode")
    // @ManagerAuth(memo = "条码扫描仪实时数据")
    // public R barcodeLatestData(){
src/main/java/com/zy/asrs/domain/vo/StationTaskTraceEventVo.java
New file
@@ -0,0 +1,25 @@
package com.zy.asrs.domain.vo;
import lombok.Data;
import java.util.Map;
@Data
public class StationTaskTraceEventVo {
    private Long timestamp;
    private String eventType;
    private String message;
    private String status;
    private Integer currentStationId;
    private Integer targetStationId;
    private Integer traceVersion;
    private Map<String, Object> details;
}
src/main/java/com/zy/asrs/domain/vo/StationTaskTraceSegmentVo.java
New file
@@ -0,0 +1,25 @@
package com.zy.asrs.domain.vo;
import lombok.Data;
import java.util.List;
@Data
public class StationTaskTraceSegmentVo {
    private Integer segmentNo;
    private Integer segmentCount;
    private Integer stationId;
    private Integer targetStationId;
    private Integer segmentStartIndex;
    private Integer segmentEndIndex;
    private List<Integer> segmentPath;
    private Boolean issued;
}
src/main/java/com/zy/asrs/domain/vo/StationTaskTraceVo.java
New file
@@ -0,0 +1,45 @@
package com.zy.asrs.domain.vo;
import lombok.Data;
import java.util.List;
@Data
public class StationTaskTraceVo {
    private Integer taskNo;
    private String threadImpl;
    private String status;
    private Integer traceVersion;
    private Integer startStationId;
    private Integer currentStationId;
    private Integer finalTargetStationId;
    private Integer blockedStationId;
    private List<Integer> fullPathStationIds;
    private List<Integer> issuedStationIds;
    private List<Integer> passedStationIds;
    private List<Integer> pendingStationIds;
    private List<Integer> latestIssuedSegmentPath;
    private List<StationTaskTraceSegmentVo> segmentList;
    private Integer issuedSegmentCount;
    private Integer totalSegmentCount;
    private Long updatedAt;
    private List<StationTaskTraceEventVo> events;
}
src/main/java/com/zy/asrs/ws/ConsoleWebSocket.java
@@ -109,6 +109,9 @@
            } else if ("/console/latest/data/fake/trace".equals(url)) {
                ConsoleController consoleController = SpringUtils.getBean(ConsoleController.class);
                resObj = consoleController.fakeTaskTraceLatestData();
            } else if ("/console/latest/data/station/trace".equals(url)) {
                ConsoleController consoleController = SpringUtils.getBean(ConsoleController.class);
                resObj = consoleController.stationTaskTraceLatestData();
            } else if ("/crn/table/crn/state".equals(url)) {
                resObj = SpringUtils.getBean(CrnController.class).crnStateTable();
            } else if ("/rgv/table/rgv/state".equals(url)) {
src/main/java/com/zy/core/ServerBootstrap.java
@@ -137,6 +137,8 @@
                    thread = new ZyStationV3Thread(deviceConfig, redisUtil);
                } else if (deviceConfig.getThreadImpl().equals("ZyStationV4Thread")) {
                    thread = new ZyStationV4Thread(deviceConfig, redisUtil);
                } else if (deviceConfig.getThreadImpl().equals("ZyStationV5Thread")) {
                    thread = new ZyStationV5Thread(deviceConfig, redisUtil);
                } else {
                    throw new CoolException("未知的线程实现");
                }
src/main/java/com/zy/core/model/command/StationCommand.java
@@ -29,4 +29,15 @@
    // 仿真模式下可选:手工指定条码
    private String barcode;
    // V5 轨迹元数据,仅供 WCS 内部使用
    private Integer segmentNo;
    private Integer segmentCount;
    private Integer segmentStartIndex;
    private Integer segmentEndIndex;
    private Integer traceVersion;
}
src/main/java/com/zy/core/network/ZyStationConnectDriver.java
@@ -69,7 +69,8 @@
                if (deviceConfig.getFake() == 0) {
                    if ("ZyStationV3Thread".equals(deviceConfig.getThreadImpl())) {
                        connectApi = new ZyStationV3RealConnect(deviceConfig, redisUtil);
                    } else if ("ZyStationV4Thread".equals(deviceConfig.getThreadImpl())) {
                    } else if ("ZyStationV4Thread".equals(deviceConfig.getThreadImpl())
                            || "ZyStationV5Thread".equals(deviceConfig.getThreadImpl())) {
                        connectApi = new ZyStationV4RealConnect(deviceConfig, redisUtil);
                    } else {
                        connectApi = new ZyStationRealConnect(deviceConfig, redisUtil);
@@ -78,13 +79,14 @@
                    if ("ZyStationV3Thread".equals(deviceConfig.getThreadImpl())) {
                        zyStationFakeSegConnect.addFakeConnect(deviceConfig, redisUtil);
                        connectApi = zyStationFakeSegConnect;
                    } else if ("ZyStationV4Thread".equals(deviceConfig.getThreadImpl())) {
                    } else if ("ZyStationV4Thread".equals(deviceConfig.getThreadImpl())
                            || "ZyStationV5Thread".equals(deviceConfig.getThreadImpl())) {
                        zyStationV4FakeSegConnect.addFakeConnect(deviceConfig, redisUtil);
                        connectApi = zyStationV4FakeSegConnect;
                    } else {
                        fakeConfigUnsupported = true;
                        zyStationConnectApi = null;
                        log.error("旧版输送站 fake 已移除,deviceNo={}, threadImpl={}, 请切换到 ZyStationV3Thread 或 ZyStationV4Thread",
                        log.error("旧版输送站 fake 已移除,deviceNo={}, threadImpl={}, 请切换到 ZyStationV3Thread、ZyStationV4Thread 或 ZyStationV5Thread",
                                deviceConfig.getDeviceNo(), deviceConfig.getThreadImpl());
                        return false;
                    }
src/main/java/com/zy/core/network/fake/ZyStationFakeSegConnect.java
@@ -148,8 +148,13 @@
            return false;
        }
        List<Integer> path = command.getNavigatePath();
        return (path == null || path.isEmpty()) && command.getStationId() != null
                && command.getStationId().equals(command.getTargetStaNo());
        if (command.getStationId() == null || !command.getStationId().equals(command.getTargetStaNo())) {
            return false;
        }
        if (path == null || path.isEmpty()) {
            return true;
        }
        return path.size() == 1 && command.getStationId().equals(path.get(0));
    }
    private void handleDirectMoveCommand(Integer deviceNo, StationCommand command) {
src/main/java/com/zy/core/thread/impl/ZyStationV5Thread.java
New file
@@ -0,0 +1,312 @@
package com.zy.core.thread.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.common.Cools;
import com.core.common.DateUtils;
import com.core.common.SpringUtils;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.entity.BasStationOpt;
import com.zy.asrs.entity.DeviceConfig;
import com.zy.asrs.entity.DeviceDataLog;
import com.zy.asrs.service.BasDevpService;
import com.zy.asrs.service.BasStationOptService;
import com.zy.asrs.utils.Utils;
import com.zy.common.model.NavigateNode;
import com.zy.common.utils.NavigateUtils;
import com.zy.common.utils.RedisUtil;
import com.zy.core.cache.MessageQueue;
import com.zy.core.cache.OutputQueue;
import com.zy.core.enums.SlaveType;
import com.zy.core.enums.StationCommandType;
import com.zy.core.model.CommandResponse;
import com.zy.core.model.Task;
import com.zy.core.model.command.StationCommand;
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.network.DeviceConnectPool;
import com.zy.core.network.ZyStationConnectDriver;
import com.zy.core.network.entity.ZyStationStatusEntity;
import com.zy.core.thread.impl.v5.StationV5SegmentExecutor;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Data
@Slf4j
public class ZyStationV5Thread implements Runnable, com.zy.core.thread.StationThread {
    private List<StationProtocol> statusList = new ArrayList<>();
    private DeviceConfig deviceConfig;
    private RedisUtil redisUtil;
    private ZyStationConnectDriver zyStationConnectDriver;
    private int deviceLogCollectTime = 200;
    private boolean initStatus = false;
    private long deviceDataLogTime = System.currentTimeMillis();
    private ExecutorService executor = Executors.newFixedThreadPool(9999);
    private StationV5SegmentExecutor segmentExecutor;
    public ZyStationV5Thread(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.segmentExecutor = new StationV5SegmentExecutor(deviceConfig, redisUtil, this::sendCommand);
    }
    @Override
    @SuppressWarnings("InfiniteLoopStatement")
    public void run() {
        this.connect();
        deviceLogCollectTime = Utils.getDeviceLogCollectTime();
        Thread readThread = new Thread(() -> {
            while (true) {
                try {
                    if (initStatus) {
                        deviceLogCollectTime = Utils.getDeviceLogCollectTime();
                    }
                    readStatus();
                    Thread.sleep(100);
                } catch (Exception e) {
                    log.error("StationV5Thread Fail", e);
                }
            }
        }, "DevpRead-" + deviceConfig.getDeviceNo());
        readThread.start();
        Thread processThread = new Thread(() -> {
            while (true) {
                try {
                    int step = 1;
                    Task task = MessageQueue.poll(SlaveType.Devp, deviceConfig.getDeviceNo());
                    if (task != null) {
                        step = task.getStep();
                    }
                    if (step == 2) {
                        StationCommand cmd = (StationCommand) task.getData();
                        executor.submit(() -> segmentExecutor.execute(cmd));
                    }
                    Thread.sleep(100);
                } catch (Exception e) {
                    log.error("StationV5Process Fail", e);
                }
            }
        }, "DevpProcess-" + deviceConfig.getDeviceNo());
        processThread.start();
    }
    private void readStatus() {
        if (zyStationConnectDriver == null) {
            return;
        }
        if (statusList.isEmpty()) {
            BasDevpService basDevpService = null;
            try {
                basDevpService = SpringUtils.getBean(BasDevpService.class);
            } catch (Exception ignore) {
            }
            if (basDevpService == null) {
                return;
            }
            BasDevp basDevp = basDevpService
                    .getOne(new QueryWrapper<BasDevp>().eq("devp_no", deviceConfig.getDeviceNo()));
            if (basDevp == null) {
                return;
            }
            List<ZyStationStatusEntity> list = JSONObject.parseArray(basDevp.getStationList(), ZyStationStatusEntity.class);
            for (ZyStationStatusEntity entity : list) {
                StationProtocol stationProtocol = new StationProtocol();
                stationProtocol.setStationId(entity.getStationId());
                statusList.add(stationProtocol);
            }
            initStatus = true;
        }
        List<ZyStationStatusEntity> zyStationStatusEntities = zyStationConnectDriver.getStatus();
        for (ZyStationStatusEntity statusEntity : zyStationStatusEntities) {
            for (StationProtocol stationProtocol : statusList) {
                if (stationProtocol.getStationId().equals(statusEntity.getStationId())) {
                    stationProtocol.setTaskNo(statusEntity.getTaskNo());
                    stationProtocol.setTargetStaNo(statusEntity.getTargetStaNo());
                    stationProtocol.setAutoing(statusEntity.isAutoing());
                    stationProtocol.setLoading(statusEntity.isLoading());
                    stationProtocol.setInEnable(statusEntity.isInEnable());
                    stationProtocol.setOutEnable(statusEntity.isOutEnable());
                    stationProtocol.setEmptyMk(statusEntity.isEmptyMk());
                    stationProtocol.setFullPlt(statusEntity.isFullPlt());
                    stationProtocol.setPalletHeight(statusEntity.getPalletHeight());
                    stationProtocol.setError(statusEntity.getError());
                    stationProtocol.setErrorMsg(statusEntity.getErrorMsg());
                    stationProtocol.setBarcode(statusEntity.getBarcode());
                    stationProtocol.setRunBlock(statusEntity.isRunBlock());
                    stationProtocol.setEnableIn(statusEntity.isEnableIn());
                    stationProtocol.setWeight(statusEntity.getWeight());
                    stationProtocol.setTaskWriteIdx(statusEntity.getTaskWriteIdx());
                }
                if (!Cools.isEmpty(stationProtocol.getSystemWarning())) {
                    if (stationProtocol.isAutoing() && !stationProtocol.isLoading()) {
                        stationProtocol.setSystemWarning("");
                    }
                }
            }
        }
        OutputQueue.DEVP.offer(MessageFormat.format("【{0}】[id:{1}] <<<<< 实时数据更新成功",
                DateUtils.convert(new Date()), deviceConfig.getDeviceNo()));
        if (System.currentTimeMillis() - deviceDataLogTime > deviceLogCollectTime) {
            DeviceDataLog deviceDataLog = new DeviceDataLog();
            deviceDataLog.setOriginData(JSON.toJSONString(zyStationStatusEntities));
            deviceDataLog.setWcsData(JSON.toJSONString(statusList));
            deviceDataLog.setType(String.valueOf(SlaveType.Devp));
            deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            deviceDataLogTime = System.currentTimeMillis();
        }
    }
    @Override
    public boolean connect() {
        zyStationConnectDriver = new ZyStationConnectDriver(deviceConfig, redisUtil);
        zyStationConnectDriver.start();
        DeviceConnectPool.put(SlaveType.Devp, deviceConfig.getDeviceNo(), zyStationConnectDriver);
        return true;
    }
    @Override
    public void close() {
        if (zyStationConnectDriver != null) {
            zyStationConnectDriver.close();
        }
        if (executor != null) {
            try {
                executor.shutdownNow();
            } catch (Exception ignore) {
            }
        }
    }
    @Override
    public List<StationProtocol> getStatus() {
        return statusList;
    }
    @Override
    public Map<Integer, StationProtocol> getStatusMap() {
        Map<Integer, StationProtocol> map = new HashMap<>();
        for (StationProtocol stationProtocol : statusList) {
            map.put(stationProtocol.getStationId(), stationProtocol);
        }
        return map;
    }
    @Override
    public StationCommand getCommand(StationCommandType commandType,
                                     Integer taskNo,
                                     Integer stationId,
                                     Integer targetStationId,
                                     Integer palletSize) {
        StationCommand stationCommand = new StationCommand();
        stationCommand.setTaskNo(taskNo);
        stationCommand.setStationId(stationId);
        stationCommand.setTargetStaNo(targetStationId);
        stationCommand.setPalletSize(palletSize);
        stationCommand.setCommandType(commandType);
        if (commandType == StationCommandType.MOVE && !stationId.equals(targetStationId)) {
            List<NavigateNode> nodes = calcPathNavigateNodes(stationId, targetStationId);
            List<Integer> path = new ArrayList<>();
            List<Integer> liftTransferPath = new ArrayList<>();
            for (NavigateNode n : nodes) {
                JSONObject v = JSONObject.parseObject(n.getNodeValue());
                if (v == null) {
                    continue;
                }
                Integer stationNo = v.getInteger("stationId");
                if (stationNo == null) {
                    continue;
                }
                path.add(stationNo);
                if (Boolean.TRUE.equals(n.getIsLiftTransferPoint())) {
                    liftTransferPath.add(stationNo);
                }
            }
            stationCommand.setNavigatePath(path);
            stationCommand.setLiftTransferPath(liftTransferPath);
        }
        return stationCommand;
    }
    @Override
    public CommandResponse sendCommand(StationCommand command) {
        CommandResponse commandResponse = null;
        try {
            commandResponse = zyStationConnectDriver.sendCommand(command);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            BasStationOptService optService = SpringUtils.getBean(BasStationOptService.class);
            List<ZyStationStatusEntity> statusListEntity = zyStationConnectDriver.getStatus();
            ZyStationStatusEntity matched = null;
            if (statusListEntity != null) {
                for (ZyStationStatusEntity entity : statusListEntity) {
                    if (entity.getStationId() != null && entity.getStationId().equals(command.getStationId())) {
                        matched = entity;
                        break;
                    }
                }
            }
            BasStationOpt basStationOpt = new BasStationOpt(
                    command.getTaskNo(),
                    command.getStationId(),
                    new Date(),
                    String.valueOf(command.getCommandType()),
                    command.getStationId(),
                    command.getTargetStaNo(),
                    null,
                    null,
                    null,
                    JSON.toJSONString(command),
                    JSON.toJSONString(matched),
                    1,
                    JSON.toJSONString(commandResponse)
            );
            if (optService != null) {
                optService.save(basStationOpt);
            }
        }
        return commandResponse;
    }
    @Override
    public CommandResponse sendOriginCommand(String address, short[] data) {
        return zyStationConnectDriver.sendOriginCommand(address, data);
    }
    @Override
    public byte[] readOriginCommand(String address, int length) {
        return zyStationConnectDriver.readOriginCommand(address, length);
    }
    private List<NavigateNode> calcPathNavigateNodes(Integer startStationId, Integer targetStationId) {
        NavigateUtils navigateUtils = SpringUtils.getBean(NavigateUtils.class);
        if (navigateUtils == null) {
            return new ArrayList<>();
        }
        return navigateUtils.calcByStationId(startStationId, targetStationId);
    }
}
src/main/java/com/zy/core/thread/impl/v5/StationV5SegmentExecutionPlan.java
New file
@@ -0,0 +1,19 @@
package com.zy.core.thread.impl.v5;
import com.zy.core.model.command.StationCommand;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class StationV5SegmentExecutionPlan {
    private List<Integer> fullPathStationIds = new ArrayList<>();
    private List<StationCommand> segmentCommands = new ArrayList<>();
    public int getTotalSegmentCount() {
        return segmentCommands == null ? 0 : segmentCommands.size();
    }
}
src/main/java/com/zy/core/thread/impl/v5/StationV5SegmentExecutor.java
New file
@@ -0,0 +1,347 @@
package com.zy.core.thread.impl.v5;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.core.common.Cools;
import com.core.common.SpringUtils;
import com.zy.asrs.domain.vo.StationTaskTraceSegmentVo;
import com.zy.asrs.entity.DeviceConfig;
import com.zy.common.utils.RedisUtil;
import com.zy.core.cache.SlaveConnection;
import com.zy.core.enums.RedisKeyType;
import com.zy.core.enums.SlaveType;
import com.zy.core.enums.StationCommandType;
import com.zy.core.model.CommandResponse;
import com.zy.core.model.command.StationCommand;
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.trace.StationTaskTraceRegistry;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class StationV5SegmentExecutor {
    private static final String CFG_STATION_COMMAND_SEGMENT_ADVANCE_RATIO = "stationCommandSegmentAdvanceRatio";
    private static final double DEFAULT_STATION_COMMAND_SEGMENT_ADVANCE_RATIO = 0.3d;
    private static final long CURRENT_STATION_TIMEOUT_MS = 1000L * 60L;
    private final DeviceConfig deviceConfig;
    private final RedisUtil redisUtil;
    private final Function<StationCommand, CommandResponse> commandSender;
    private final StationV5SegmentPlanner segmentPlanner = new StationV5SegmentPlanner();
    public StationV5SegmentExecutor(DeviceConfig deviceConfig,
                                    RedisUtil redisUtil,
                                    Function<StationCommand, CommandResponse> commandSender) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.commandSender = commandSender;
    }
    public void execute(StationCommand original) {
        if (original == null) {
            return;
        }
        if (original.getCommandType() != StationCommandType.MOVE) {
            commandSender.apply(original);
            return;
        }
        StationV5SegmentExecutionPlan localPlan = segmentPlanner.buildPlan(original);
        if (localPlan.getSegmentCommands().isEmpty()) {
            return;
        }
        StationTaskTraceRegistry traceRegistry = SpringUtils.getBean(StationTaskTraceRegistry.class);
        StationTaskTraceRegistry.TraceRegistration traceRegistration = traceRegistry == null
                ? new StationTaskTraceRegistry.TraceRegistration()
                : traceRegistry.registerPlan(original.getTaskNo(), deviceConfig.getThreadImpl(),
                original.getStationId(), original.getStationId(), original.getTargetStaNo(),
                localPlan.getFullPathStationIds(), buildTraceSegments(localPlan.getSegmentCommands()));
        int traceVersion = traceRegistration.getTraceVersion() == null ? 1 : traceRegistration.getTraceVersion();
        int pathOffset = traceRegistration.getPathOffset() == null ? 0 : traceRegistration.getPathOffset();
        bindCommands(localPlan.getSegmentCommands(), traceVersion, pathOffset);
        List<Integer> effectiveFullPath = traceRegistration.getFullPathStationIds() == null
                || traceRegistration.getFullPathStationIds().isEmpty()
                ? copyIntegerList(localPlan.getFullPathStationIds())
                : copyIntegerList(traceRegistration.getFullPathStationIds());
        StationCommand firstCommand = localPlan.getSegmentCommands().get(0);
        if (!sendSegmentWithRetry(firstCommand)) {
            return;
        }
        if (traceRegistry != null) {
            traceRegistry.markSegmentIssued(original.getTaskNo(), traceVersion, firstCommand,
                    "FIRST_SEGMENT_SENT", "输送任务首段下发成功", buildSegmentDetails(firstCommand));
        }
        long lastSeenAt = System.currentTimeMillis();
        int segCursor = 0;
        Integer lastCurrentStationId = null;
        boolean firstRun = true;
        double segmentAdvanceRatio = loadSegmentAdvanceRatio();
        while (true) {
            try {
                Object cancel = redisUtil.get(RedisKeyType.DEVICE_STATION_MOVE_RESET.key + original.getTaskNo());
                if (cancel != null) {
                    if (traceRegistry != null) {
                        traceRegistry.markCancelled(original.getTaskNo(), traceVersion, lastCurrentStationId,
                                buildDetails("reason", "redis_cancel_signal"));
                    }
                    break;
                }
                StationProtocol currentStation = findCurrentStationByTask(original.getTaskNo());
                if (currentStation == null) {
                    if (System.currentTimeMillis() - lastSeenAt > CURRENT_STATION_TIMEOUT_MS) {
                        if (traceRegistry != null) {
                            traceRegistry.markTimeout(original.getTaskNo(), traceVersion, lastCurrentStationId,
                                    buildDetails("timeoutMs", CURRENT_STATION_TIMEOUT_MS));
                        }
                        break;
                    }
                    Thread.sleep(500L);
                    continue;
                }
                lastSeenAt = System.currentTimeMillis();
                Integer previousCurrentStationId = lastCurrentStationId;
                if (traceRegistry != null) {
                    traceRegistry.updateProgress(original.getTaskNo(), traceVersion, currentStation.getStationId(),
                            equalsInteger(previousCurrentStationId, currentStation.getStationId()) ? null : "CURRENT_STATION_CHANGE",
                            "输送任务当前位置已更新",
                            buildDetails("stationId", currentStation.getStationId()));
                }
                lastCurrentStationId = currentStation.getStationId();
                if (!firstRun && currentStation.isRunBlock()) {
                    if (traceRegistry != null) {
                        traceRegistry.markBlocked(original.getTaskNo(), traceVersion, currentStation.getStationId(),
                                buildDetails("blockedStationId", currentStation.getStationId()));
                    }
                    break;
                }
                int currentIndex = effectiveFullPath.indexOf(currentStation.getStationId());
                if (currentIndex < 0) {
                    Thread.sleep(500L);
                    firstRun = false;
                    continue;
                }
                int remaining = effectiveFullPath.size() - currentIndex - 1;
                if (remaining <= 0) {
                    if (traceRegistry != null) {
                        traceRegistry.markFinished(original.getTaskNo(), traceVersion, currentStation.getStationId(),
                                buildDetails("targetStationId", original.getTargetStaNo()));
                    }
                    break;
                }
                StationCommand currentSegmentCommand = localPlan.getSegmentCommands().get(segCursor);
                int currentSegEndIndex = safeIndex(currentSegmentCommand.getSegmentEndIndex());
                int currentSegStartIndex = safeIndex(currentSegmentCommand.getSegmentStartIndex());
                int segLen = Math.max(1, currentSegEndIndex - currentSegStartIndex + 1);
                int remainingSegment = Math.max(0, currentSegEndIndex - currentIndex);
                int thresholdSegment = (int) Math.ceil(segLen * segmentAdvanceRatio);
                if (remainingSegment <= thresholdSegment && segCursor < localPlan.getSegmentCommands().size() - 1) {
                    StationCommand nextCommand = localPlan.getSegmentCommands().get(segCursor + 1);
                    if (sendSegmentWithRetry(nextCommand)) {
                        segCursor++;
                        if (traceRegistry != null) {
                            traceRegistry.markSegmentIssued(original.getTaskNo(), traceVersion, nextCommand,
                                    "NEXT_SEGMENT_SENT", "输送任务下一段已提前下发",
                                    buildSegmentDetails(nextCommand));
                        }
                    }
                }
                Thread.sleep(500L);
                firstRun = false;
            } catch (Exception ignore) {
                break;
            }
        }
    }
    private boolean sendSegmentWithRetry(StationCommand command) {
        while (true) {
            CommandResponse commandResponse = commandSender.apply(command);
            if (commandResponse == null) {
                sleepQuietly(200L);
                continue;
            }
            if (commandResponse.getResult()) {
                return true;
            }
            sleepQuietly(200L);
        }
    }
    private double loadSegmentAdvanceRatio() {
        try {
            ConfigService configService = SpringUtils.getBean(ConfigService.class);
            if (configService == null) {
                return DEFAULT_STATION_COMMAND_SEGMENT_ADVANCE_RATIO;
            }
            Config config = configService.getOne(new QueryWrapper<Config>()
                    .eq("code", CFG_STATION_COMMAND_SEGMENT_ADVANCE_RATIO));
            if (config == null || Cools.isEmpty(config.getValue())) {
                return DEFAULT_STATION_COMMAND_SEGMENT_ADVANCE_RATIO;
            }
            return normalizeSegmentAdvanceRatio(config.getValue());
        } catch (Exception ignore) {
            return DEFAULT_STATION_COMMAND_SEGMENT_ADVANCE_RATIO;
        }
    }
    private double normalizeSegmentAdvanceRatio(String valueText) {
        if (valueText == null) {
            return DEFAULT_STATION_COMMAND_SEGMENT_ADVANCE_RATIO;
        }
        String text = valueText.trim();
        if (text.isEmpty()) {
            return DEFAULT_STATION_COMMAND_SEGMENT_ADVANCE_RATIO;
        }
        if (text.endsWith("%")) {
            text = text.substring(0, text.length() - 1).trim();
        }
        try {
            double ratio = Double.parseDouble(text);
            if (ratio > 1d && ratio <= 100d) {
                ratio = ratio / 100d;
            }
            if (ratio < 0d) {
                return 0d;
            }
            if (ratio > 1d) {
                return 1d;
            }
            return ratio;
        } catch (Exception ignore) {
            return DEFAULT_STATION_COMMAND_SEGMENT_ADVANCE_RATIO;
        }
    }
    private StationProtocol findCurrentStationByTask(Integer taskNo) {
        try {
            com.zy.asrs.service.DeviceConfigService deviceConfigService = SpringUtils.getBean(com.zy.asrs.service.DeviceConfigService.class);
            if (deviceConfigService == null) {
                return null;
            }
            List<DeviceConfig> devpList = deviceConfigService.list(new QueryWrapper<DeviceConfig>()
                    .eq("device_type", String.valueOf(SlaveType.Devp)));
            for (DeviceConfig dc : devpList) {
                com.zy.core.thread.StationThread thread = (com.zy.core.thread.StationThread) SlaveConnection.get(SlaveType.Devp, dc.getDeviceNo());
                if (thread == null) {
                    continue;
                }
                Map<Integer, StationProtocol> statusMap = thread.getStatusMap();
                if (statusMap == null || statusMap.isEmpty()) {
                    continue;
                }
                for (StationProtocol protocol : statusMap.values()) {
                    if (protocol.getTaskNo() != null && protocol.getTaskNo().equals(taskNo) && protocol.isLoading()) {
                        return protocol;
                    }
                }
            }
        } catch (Exception ignore) {
            return null;
        }
        return null;
    }
    private List<StationTaskTraceSegmentVo> buildTraceSegments(List<StationCommand> segmentCommands) {
        List<StationTaskTraceSegmentVo> result = new ArrayList<>();
        if (segmentCommands == null) {
            return result;
        }
        for (StationCommand command : segmentCommands) {
            if (command == null) {
                continue;
            }
            StationTaskTraceSegmentVo item = new StationTaskTraceSegmentVo();
            item.setSegmentNo(command.getSegmentNo());
            item.setSegmentCount(command.getSegmentCount());
            item.setStationId(command.getStationId());
            item.setTargetStationId(command.getTargetStaNo());
            item.setSegmentStartIndex(command.getSegmentStartIndex());
            item.setSegmentEndIndex(command.getSegmentEndIndex());
            item.setSegmentPath(copyIntegerList(command.getNavigatePath()));
            item.setIssued(Boolean.FALSE);
            result.add(item);
        }
        return result;
    }
    private void bindCommands(List<StationCommand> segmentCommands, int traceVersion, int pathOffset) {
        if (segmentCommands == null) {
            return;
        }
        for (StationCommand command : segmentCommands) {
            if (command == null) {
                continue;
            }
            command.setTraceVersion(traceVersion);
            if (command.getSegmentStartIndex() != null) {
                command.setSegmentStartIndex(command.getSegmentStartIndex() + pathOffset);
            }
            if (command.getSegmentEndIndex() != null) {
                command.setSegmentEndIndex(command.getSegmentEndIndex() + pathOffset);
            }
        }
    }
    private Map<String, Object> buildSegmentDetails(StationCommand command) {
        Map<String, Object> details = new LinkedHashMap<>();
        if (command != null) {
            details.put("segmentNo", command.getSegmentNo());
            details.put("segmentCount", command.getSegmentCount());
            details.put("segmentPath", copyIntegerList(command.getNavigatePath()));
            details.put("segmentStartIndex", command.getSegmentStartIndex());
            details.put("segmentEndIndex", command.getSegmentEndIndex());
            details.put("traceVersion", command.getTraceVersion());
        }
        return details;
    }
    private Map<String, Object> buildDetails(Object... keyValues) {
        Map<String, Object> details = new LinkedHashMap<>();
        if (keyValues == null) {
            return details;
        }
        for (int i = 0; i + 1 < keyValues.length; i += 2) {
            Object key = keyValues[i];
            if (key != null) {
                details.put(String.valueOf(key), keyValues[i + 1]);
            }
        }
        return details;
    }
    private List<Integer> copyIntegerList(List<Integer> source) {
        List<Integer> result = new ArrayList<>();
        if (source == null) {
            return result;
        }
        result.addAll(source);
        return result;
    }
    private int safeIndex(Integer value) {
        return value == null ? -1 : value;
    }
    private boolean equalsInteger(Integer a, Integer b) {
        return a != null && a.equals(b);
    }
    private void sleepQuietly(long millis) {
        try {
            Thread.sleep(millis);
        } catch (Exception ignore) {
        }
    }
}
src/main/java/com/zy/core/thread/impl/v5/StationV5SegmentPlanner.java
New file
@@ -0,0 +1,96 @@
package com.zy.core.thread.impl.v5;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.zy.core.model.command.StationCommand;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class StationV5SegmentPlanner {
    public StationV5SegmentExecutionPlan buildPlan(StationCommand original) {
        StationV5SegmentExecutionPlan plan = new StationV5SegmentExecutionPlan();
        if (original == null) {
            return plan;
        }
        List<Integer> path = copyIntegerList(original.getNavigatePath());
        List<Integer> liftTransferPath = copyIntegerList(original.getLiftTransferPath());
        Integer startStationId = original.getStationId();
        Integer targetStationId = original.getTargetStaNo();
        if ((path == null || path.isEmpty()) && Objects.equals(startStationId, targetStationId) && startStationId != null) {
            path = new ArrayList<>();
            path.add(startStationId);
        }
        if (path == null || path.isEmpty()) {
            return plan;
        }
        plan.setFullPathStationIds(copyIntegerList(path));
        int total = path.size();
        List<Integer> segmentEndIndices = new ArrayList<>();
        if (liftTransferPath != null) {
            for (Integer liftTransferStationId : liftTransferPath) {
                int endIndex = path.indexOf(liftTransferStationId);
                if (endIndex <= 0) {
                    continue;
                }
                if (segmentEndIndices.isEmpty() || endIndex > segmentEndIndices.get(segmentEndIndices.size() - 1)) {
                    segmentEndIndices.add(endIndex);
                }
            }
        }
        if (segmentEndIndices.isEmpty() || segmentEndIndices.get(segmentEndIndices.size() - 1) != total - 1) {
            segmentEndIndices.add(total - 1);
        }
        List<StationCommand> segmentCommands = new ArrayList<>();
        int buildStartIdx = 0;
        for (Integer endIdx : segmentEndIndices) {
            if (endIdx == null || endIdx < buildStartIdx) {
                continue;
            }
            List<Integer> segmentPath = new ArrayList<>(path.subList(buildStartIdx, endIdx + 1));
            if (segmentPath.isEmpty()) {
                buildStartIdx = endIdx + 1;
                continue;
            }
            StationCommand segmentCommand = new StationCommand();
            segmentCommand.setTaskNo(original.getTaskNo());
            segmentCommand.setCommandType(original.getCommandType());
            segmentCommand.setPalletSize(original.getPalletSize());
            segmentCommand.setBarcode(original.getBarcode());
            segmentCommand.setOriginalNavigatePath(copyIntegerList(path));
            segmentCommand.setNavigatePath(segmentPath);
            segmentCommand.setStationId(segmentPath.get(0));
            segmentCommand.setTargetStaNo(segmentPath.get(segmentPath.size() - 1));
            segmentCommand.setSegmentStartIndex(buildStartIdx);
            segmentCommand.setSegmentEndIndex(endIdx);
            segmentCommands.add(segmentCommand);
            buildStartIdx = endIdx;
        }
        int segmentCount = segmentCommands.size();
        for (int i = 0; i < segmentCommands.size(); i++) {
            StationCommand segmentCommand = segmentCommands.get(i);
            segmentCommand.setSegmentNo(i + 1);
            segmentCommand.setSegmentCount(segmentCount);
        }
        plan.setSegmentCommands(segmentCommands);
        return plan;
    }
    private List<Integer> copyIntegerList(List<Integer> source) {
        if (source == null) {
            return new ArrayList<>();
        }
        return JSON.parseArray(JSON.toJSONString(source, SerializerFeature.DisableCircularReferenceDetect), Integer.class);
    }
}
src/main/java/com/zy/core/trace/StationTaskTraceRegistry.java
New file
@@ -0,0 +1,517 @@
package com.zy.core.trace;
import com.zy.asrs.domain.vo.StationTaskTraceEventVo;
import com.zy.asrs.domain.vo.StationTaskTraceSegmentVo;
import com.zy.asrs.domain.vo.StationTaskTraceVo;
import com.zy.core.model.command.StationCommand;
import lombok.Data;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class StationTaskTraceRegistry {
    private static final int MAX_EVENT_COUNT = 200;
    private static final long TERMINAL_KEEP_MS = 30L * 60L * 1000L;
    public static final String STATUS_WAITING = "WAITING";
    public static final String STATUS_RUNNING = "RUNNING";
    public static final String STATUS_BLOCKED = "BLOCKED";
    public static final String STATUS_CANCELLED = "CANCELLED";
    public static final String STATUS_TIMEOUT = "TIMEOUT";
    public static final String STATUS_FINISHED = "FINISHED";
    public static final String STATUS_REROUTED = "REROUTED";
    private final Map<Integer, TraceTaskState> taskStateMap = new ConcurrentHashMap<>();
    public TraceRegistration registerPlan(Integer taskNo,
                                          String threadImpl,
                                          Integer startStationId,
                                          Integer currentStationId,
                                          Integer finalTargetStationId,
                                          List<Integer> localPathStationIds,
                                          List<StationTaskTraceSegmentVo> localSegmentList) {
        if (taskNo == null || taskNo <= 0) {
            return new TraceRegistration();
        }
        cleanupExpired();
        TraceTaskState taskState = taskStateMap.computeIfAbsent(taskNo, TraceTaskState::new);
        return taskState.registerPlan(threadImpl, startStationId, currentStationId, finalTargetStationId,
                copyIntegerList(localPathStationIds), copySegmentList(localSegmentList));
    }
    public void markSegmentIssued(Integer taskNo,
                                  Integer traceVersion,
                                  StationCommand command,
                                  String eventType,
                                  String message,
                                  Map<String, Object> details) {
        TraceTaskState state = taskStateMap.get(taskNo);
        if (state == null) {
            return;
        }
        state.markSegmentIssued(traceVersion, command, eventType, message, details);
    }
    public void updateProgress(Integer taskNo,
                               Integer traceVersion,
                               Integer currentStationId,
                               String eventType,
                               String message,
                               Map<String, Object> details) {
        TraceTaskState state = taskStateMap.get(taskNo);
        if (state == null) {
            return;
        }
        state.updateProgress(traceVersion, currentStationId, eventType, message, details);
    }
    public void markBlocked(Integer taskNo,
                            Integer traceVersion,
                            Integer currentStationId,
                            Map<String, Object> details) {
        TraceTaskState state = taskStateMap.get(taskNo);
        if (state == null) {
            return;
        }
        state.markTerminal(traceVersion, STATUS_BLOCKED, currentStationId, currentStationId,
                "BLOCKED", "输送任务运行堵塞", details);
    }
    public void markCancelled(Integer taskNo,
                              Integer traceVersion,
                              Integer currentStationId,
                              Map<String, Object> details) {
        TraceTaskState state = taskStateMap.get(taskNo);
        if (state == null) {
            return;
        }
        state.markTerminal(traceVersion, STATUS_CANCELLED, currentStationId, null,
                "CANCELLED", "输送任务收到取消信号", details);
    }
    public void markTimeout(Integer taskNo,
                            Integer traceVersion,
                            Integer currentStationId,
                            Map<String, Object> details) {
        TraceTaskState state = taskStateMap.get(taskNo);
        if (state == null) {
            return;
        }
        state.markTerminal(traceVersion, STATUS_TIMEOUT, currentStationId, null,
                "TIMEOUT", "输送任务长时间无法定位当前位置", details);
    }
    public void markFinished(Integer taskNo,
                             Integer traceVersion,
                             Integer currentStationId,
                             Map<String, Object> details) {
        TraceTaskState state = taskStateMap.get(taskNo);
        if (state == null) {
            return;
        }
        state.markTerminal(traceVersion, STATUS_FINISHED, currentStationId, null,
                "FINISHED", "输送任务轨迹完成", details);
    }
    public List<StationTaskTraceVo> listLatestTraces() {
        cleanupExpired();
        List<StationTaskTraceVo> result = new ArrayList<>();
        for (TraceTaskState state : taskStateMap.values()) {
            if (state != null) {
                result.add(state.toVo());
            }
        }
        result.sort(new Comparator<StationTaskTraceVo>() {
            @Override
            public int compare(StationTaskTraceVo a, StationTaskTraceVo b) {
                long av = a.getUpdatedAt() == null ? 0L : a.getUpdatedAt();
                long bv = b.getUpdatedAt() == null ? 0L : b.getUpdatedAt();
                return Long.compare(bv, av);
            }
        });
        return result;
    }
    private void cleanupExpired() {
        long now = System.currentTimeMillis();
        for (Map.Entry<Integer, TraceTaskState> entry : taskStateMap.entrySet()) {
            TraceTaskState state = entry.getValue();
            if (state != null && state.shouldRemove(now)) {
                taskStateMap.remove(entry.getKey(), state);
            }
        }
    }
    private static List<Integer> copyIntegerList(List<Integer> source) {
        List<Integer> result = new ArrayList<>();
        if (source == null) {
            return result;
        }
        for (Integer item : source) {
            if (item != null) {
                result.add(item);
            }
        }
        return result;
    }
    private static List<StationTaskTraceSegmentVo> copySegmentList(List<StationTaskTraceSegmentVo> source) {
        List<StationTaskTraceSegmentVo> result = new ArrayList<>();
        if (source == null) {
            return result;
        }
        for (StationTaskTraceSegmentVo item : source) {
            if (item == null) {
                continue;
            }
            StationTaskTraceSegmentVo copy = new StationTaskTraceSegmentVo();
            copy.setSegmentNo(item.getSegmentNo());
            copy.setSegmentCount(item.getSegmentCount());
            copy.setStationId(item.getStationId());
            copy.setTargetStationId(item.getTargetStationId());
            copy.setSegmentStartIndex(item.getSegmentStartIndex());
            copy.setSegmentEndIndex(item.getSegmentEndIndex());
            copy.setSegmentPath(copyIntegerList(item.getSegmentPath()));
            copy.setIssued(Boolean.TRUE.equals(item.getIssued()));
            result.add(copy);
        }
        return result;
    }
    private static Map<String, Object> copyDetails(Map<String, Object> source) {
        Map<String, Object> result = new LinkedHashMap<>();
        if (source == null || source.isEmpty()) {
            return result;
        }
        for (Map.Entry<String, Object> entry : source.entrySet()) {
            Object value = entry.getValue();
            if (value instanceof List) {
                result.put(entry.getKey(), new ArrayList<>((List<?>) value));
            } else if (value instanceof Map) {
                result.put(entry.getKey(), new LinkedHashMap<>((Map<?, ?>) value));
            } else {
                result.put(entry.getKey(), value);
            }
        }
        return result;
    }
    private static boolean isTerminalStatus(String status) {
        return STATUS_BLOCKED.equals(status)
                || STATUS_CANCELLED.equals(status)
                || STATUS_TIMEOUT.equals(status)
                || STATUS_FINISHED.equals(status);
    }
    @Data
    public static class TraceRegistration {
        private Integer traceVersion;
        private Integer pathOffset;
        private List<Integer> fullPathStationIds = new ArrayList<>();
        private boolean rerouted;
    }
    private static class TraceTaskState {
        private final Integer taskNo;
        private String threadImpl;
        private String status = STATUS_WAITING;
        private Integer traceVersion = 0;
        private Integer startStationId;
        private Integer currentStationId;
        private Integer finalTargetStationId;
        private Integer blockedStationId;
        private List<Integer> fullPathStationIds = new ArrayList<>();
        private List<Integer> issuedStationIds = new ArrayList<>();
        private List<Integer> passedStationIds = new ArrayList<>();
        private List<Integer> pendingStationIds = new ArrayList<>();
        private List<Integer> latestIssuedSegmentPath = new ArrayList<>();
        private List<StationTaskTraceSegmentVo> segmentList = new ArrayList<>();
        private Integer issuedSegmentCount = 0;
        private Integer totalSegmentCount = 0;
        private final List<StationTaskTraceEventVo> events = new ArrayList<>();
        private Long updatedAt = System.currentTimeMillis();
        private Long terminalExpireAt;
        private TraceTaskState(Integer taskNo) {
            this.taskNo = taskNo;
        }
        private synchronized TraceRegistration registerPlan(String threadImpl,
                                                            Integer startStationId,
                                                            Integer currentStationId,
                                                            Integer finalTargetStationId,
                                                            List<Integer> localPathStationIds,
                                                            List<StationTaskTraceSegmentVo> localSegmentList) {
            TraceRegistration registration = new TraceRegistration();
            boolean rerouted = !isTerminalStatus(this.status) && this.traceVersion != null && this.traceVersion > 0;
            int nextTraceVersion = rerouted ? this.traceVersion + 1 : 1;
            int pathOffset = rerouted ? this.passedStationIds.size() : 0;
            Integer planCurrentStationId = currentStationId != null ? currentStationId : startStationId;
            List<Integer> nextFullPath = buildFullPathForRegistration(rerouted, planCurrentStationId, localPathStationIds);
            List<StationTaskTraceSegmentVo> nextSegmentList = shiftSegments(copySegmentList(localSegmentList), pathOffset);
            this.threadImpl = threadImpl;
            this.traceVersion = nextTraceVersion;
            this.startStationId = rerouted && this.startStationId != null ? this.startStationId : startStationId;
            this.currentStationId = planCurrentStationId;
            this.finalTargetStationId = finalTargetStationId;
            this.blockedStationId = null;
            this.fullPathStationIds = nextFullPath;
            this.segmentList = nextSegmentList;
            this.issuedSegmentCount = 0;
            this.totalSegmentCount = nextSegmentList.size();
            this.issuedStationIds = new ArrayList<>();
            this.latestIssuedSegmentPath = new ArrayList<>();
            this.status = rerouted ? STATUS_REROUTED : STATUS_WAITING;
            this.terminalExpireAt = null;
            rebuildProgress(planCurrentStationId);
            this.updatedAt = System.currentTimeMillis();
            Map<String, Object> details = new LinkedHashMap<>();
            details.put("traceVersion", nextTraceVersion);
            details.put("fullPathStationIds", copyIntegerList(this.fullPathStationIds));
            details.put("segmentCount", this.totalSegmentCount);
            details.put("pathOffset", pathOffset);
            details.put("currentStationId", this.currentStationId);
            appendEvent(rerouted ? "REROUTED" : "PLAN_READY",
                    rerouted ? "输送任务路径已重算并续接轨迹" : "输送任务分段计划已建立",
                    details);
            registration.setTraceVersion(nextTraceVersion);
            registration.setPathOffset(pathOffset);
            registration.setFullPathStationIds(copyIntegerList(this.fullPathStationIds));
            registration.setRerouted(rerouted);
            return registration;
        }
        private synchronized void markSegmentIssued(Integer traceVersion,
                                                    StationCommand command,
                                                    String eventType,
                                                    String message,
                                                    Map<String, Object> details) {
            if (!acceptTraceVersion(traceVersion)) {
                return;
            }
            this.status = STATUS_RUNNING;
            this.blockedStationId = null;
            int currentIssued = this.issuedSegmentCount == null ? 0 : this.issuedSegmentCount;
            int nextIssued = command == null || command.getSegmentNo() == null ? currentIssued : command.getSegmentNo();
            this.issuedSegmentCount = Math.max(currentIssued, nextIssued);
            this.latestIssuedSegmentPath = copyIntegerList(command == null ? null : command.getNavigatePath());
            int segmentEndIndex = command == null || command.getSegmentEndIndex() == null ? -1 : command.getSegmentEndIndex();
            if (segmentEndIndex >= 0 && !this.fullPathStationIds.isEmpty()) {
                int end = Math.min(segmentEndIndex + 1, this.fullPathStationIds.size());
                this.issuedStationIds = copyIntegerList(this.fullPathStationIds.subList(0, end));
            }
            this.updatedAt = System.currentTimeMillis();
            Map<String, Object> nextDetails = copyDetails(details);
            if (command != null) {
                nextDetails.put("segmentNo", command.getSegmentNo());
                nextDetails.put("segmentCount", command.getSegmentCount());
                nextDetails.put("commandStationId", command.getStationId());
                nextDetails.put("commandTargetStationId", command.getTargetStaNo());
                nextDetails.put("segmentPath", copyIntegerList(command.getNavigatePath()));
                nextDetails.put("issuedSegmentCount", this.issuedSegmentCount);
                nextDetails.put("totalSegmentCount", this.totalSegmentCount);
            }
            appendEvent(eventType, message, nextDetails);
        }
        private synchronized void updateProgress(Integer traceVersion,
                                                 Integer currentStationId,
                                                 String eventType,
                                                 String message,
                                                 Map<String, Object> details) {
            if (!acceptTraceVersion(traceVersion)) {
                return;
            }
            boolean changed = !Objects.equals(this.currentStationId, currentStationId);
            rebuildProgress(currentStationId);
            if (!isTerminalStatus(this.status)) {
                this.status = STATUS_RUNNING;
                this.blockedStationId = null;
            }
            this.updatedAt = System.currentTimeMillis();
            if (changed && eventType != null) {
                Map<String, Object> nextDetails = copyDetails(details);
                nextDetails.put("currentStationId", currentStationId);
                nextDetails.put("passedStationIds", copyIntegerList(this.passedStationIds));
                nextDetails.put("pendingStationIds", copyIntegerList(this.pendingStationIds));
                appendEvent(eventType, message, nextDetails);
            }
        }
        private synchronized void markTerminal(Integer traceVersion,
                                               String terminalStatus,
                                               Integer currentStationId,
                                               Integer blockedStationId,
                                               String eventType,
                                               String message,
                                               Map<String, Object> details) {
            if (!acceptTraceVersion(traceVersion)) {
                return;
            }
            if (currentStationId != null) {
                rebuildProgress(currentStationId);
            }
            this.status = terminalStatus;
            this.blockedStationId = blockedStationId;
            this.updatedAt = System.currentTimeMillis();
            this.terminalExpireAt = this.updatedAt + TERMINAL_KEEP_MS;
            Map<String, Object> nextDetails = copyDetails(details);
            nextDetails.put("currentStationId", this.currentStationId);
            nextDetails.put("blockedStationId", this.blockedStationId);
            nextDetails.put("passedStationIds", copyIntegerList(this.passedStationIds));
            nextDetails.put("pendingStationIds", copyIntegerList(this.pendingStationIds));
            appendEvent(eventType, message, nextDetails);
        }
        private synchronized boolean shouldRemove(long now) {
            return terminalExpireAt != null && terminalExpireAt <= now;
        }
        private synchronized StationTaskTraceVo toVo() {
            StationTaskTraceVo vo = new StationTaskTraceVo();
            vo.setTaskNo(taskNo);
            vo.setThreadImpl(threadImpl);
            vo.setStatus(status);
            vo.setTraceVersion(traceVersion);
            vo.setStartStationId(startStationId);
            vo.setCurrentStationId(currentStationId);
            vo.setFinalTargetStationId(finalTargetStationId);
            vo.setBlockedStationId(blockedStationId);
            vo.setFullPathStationIds(copyIntegerList(fullPathStationIds));
            vo.setIssuedStationIds(copyIntegerList(issuedStationIds));
            vo.setPassedStationIds(copyIntegerList(passedStationIds));
            vo.setPendingStationIds(copyIntegerList(pendingStationIds));
            vo.setLatestIssuedSegmentPath(copyIntegerList(latestIssuedSegmentPath));
            vo.setSegmentList(copySegmentListWithIssued(segmentList, issuedSegmentCount));
            vo.setIssuedSegmentCount(issuedSegmentCount);
            vo.setTotalSegmentCount(totalSegmentCount);
            vo.setUpdatedAt(updatedAt);
            vo.setEvents(new ArrayList<>(events));
            return vo;
        }
        private List<Integer> buildFullPathForRegistration(boolean rerouted,
                                                           Integer planCurrentStationId,
                                                           List<Integer> localPathStationIds) {
            List<Integer> localPath = copyIntegerList(localPathStationIds);
            if (!rerouted) {
                if (localPath.isEmpty() && planCurrentStationId != null) {
                    localPath.add(planCurrentStationId);
                }
                return localPath;
            }
            List<Integer> result = new ArrayList<>(copyIntegerList(this.passedStationIds));
            if (planCurrentStationId != null) {
                result.add(planCurrentStationId);
            }
            if (!localPath.isEmpty()) {
                int startIdx = 0;
                if (planCurrentStationId != null && Objects.equals(localPath.get(0), planCurrentStationId)) {
                    startIdx = 1;
                }
                for (int i = startIdx; i < localPath.size(); i++) {
                    Integer stationId = localPath.get(i);
                    if (stationId != null) {
                        result.add(stationId);
                    }
                }
            }
            return result;
        }
        private void rebuildProgress(Integer nextCurrentStationId) {
            this.currentStationId = nextCurrentStationId;
            List<Integer> fullPath = copyIntegerList(this.fullPathStationIds);
            this.passedStationIds = new ArrayList<>();
            this.pendingStationIds = copyIntegerList(fullPath);
            int currentIndex = nextCurrentStationId == null ? -1 : fullPath.indexOf(nextCurrentStationId);
            if (currentIndex < 0) {
                return;
            }
            this.passedStationIds = copyIntegerList(fullPath.subList(0, currentIndex));
            this.pendingStationIds = copyIntegerList(fullPath.subList(currentIndex + 1, fullPath.size()));
        }
        private boolean acceptTraceVersion(Integer incomingTraceVersion) {
            return incomingTraceVersion != null
                    && this.traceVersion != null
                    && incomingTraceVersion.intValue() == this.traceVersion.intValue();
        }
        private void appendEvent(String eventType, String message, Map<String, Object> details) {
            if (eventType == null) {
                return;
            }
            StationTaskTraceEventVo event = new StationTaskTraceEventVo();
            event.setTimestamp(System.currentTimeMillis());
            event.setEventType(eventType);
            event.setMessage(message);
            event.setStatus(this.status);
            event.setCurrentStationId(this.currentStationId);
            event.setTargetStationId(this.finalTargetStationId);
            event.setTraceVersion(this.traceVersion);
            event.setDetails(copyDetails(details));
            this.events.add(event);
            if (this.events.size() > MAX_EVENT_COUNT) {
                this.events.remove(0);
            }
        }
        private List<StationTaskTraceSegmentVo> shiftSegments(List<StationTaskTraceSegmentVo> source, int pathOffset) {
            List<StationTaskTraceSegmentVo> result = new ArrayList<>();
            for (StationTaskTraceSegmentVo item : source) {
                if (item == null) {
                    continue;
                }
                StationTaskTraceSegmentVo copy = new StationTaskTraceSegmentVo();
                copy.setSegmentNo(item.getSegmentNo());
                copy.setSegmentCount(item.getSegmentCount());
                copy.setStationId(item.getStationId());
                copy.setTargetStationId(item.getTargetStationId());
                copy.setSegmentStartIndex(item.getSegmentStartIndex() == null ? null : item.getSegmentStartIndex() + pathOffset);
                copy.setSegmentEndIndex(item.getSegmentEndIndex() == null ? null : item.getSegmentEndIndex() + pathOffset);
                copy.setSegmentPath(copyIntegerList(item.getSegmentPath()));
                copy.setIssued(Boolean.FALSE);
                result.add(copy);
            }
            return result;
        }
        private List<StationTaskTraceSegmentVo> copySegmentListWithIssued(List<StationTaskTraceSegmentVo> source, Integer issuedSegmentCount) {
            List<StationTaskTraceSegmentVo> result = new ArrayList<>();
            int issuedCount = issuedSegmentCount == null ? 0 : issuedSegmentCount;
            for (StationTaskTraceSegmentVo item : source) {
                if (item == null) {
                    continue;
                }
                StationTaskTraceSegmentVo copy = new StationTaskTraceSegmentVo();
                copy.setSegmentNo(item.getSegmentNo());
                copy.setSegmentCount(item.getSegmentCount());
                copy.setStationId(item.getStationId());
                copy.setTargetStationId(item.getTargetStationId());
                copy.setSegmentStartIndex(item.getSegmentStartIndex());
                copy.setSegmentEndIndex(item.getSegmentEndIndex());
                copy.setSegmentPath(copyIntegerList(item.getSegmentPath()));
                copy.setIssued(item.getSegmentNo() != null && item.getSegmentNo() <= issuedCount);
                result.add(copy);
            }
            return result;
        }
    }
}
src/main/webapp/components/DevpCard.js
@@ -1,3 +1,5 @@
var stationTracePageVersion = "20260319_station_trace_layout_v2";
Vue.component("devp-card", {
  template: `
    <div class="mc-root">
@@ -32,6 +34,7 @@
          <div class="mc-action-row">
            <button type="button" class="mc-btn" @click="controlCommand">下发</button>
            <button type="button" class="mc-btn mc-btn-soft" @click="resetCommand">复位</button>
            <button type="button" class="mc-btn mc-btn-ghost" @click="openStationTracePage">运行轨迹</button>
            <button v-if="showFakeTraceEntry" type="button" class="mc-btn mc-btn-ghost" @click="openFakeTracePage">仿真轨迹</button>
          </div>
        </div>
@@ -252,6 +255,9 @@
      }
      window.open(baseUrl + "/views/watch/fakeTrace.html", "_blank");
    },
    openStationTracePage: function () {
      window.open(baseUrl + "/views/watch/stationTrace.html?v=" + stationTracePageVersion, "_blank");
    },
    buildDetailEntries: function (item) {
      return [
        { label: "编号", value: this.orDash(item.stationId) },
src/main/webapp/components/MapCanvas.js
@@ -1854,7 +1854,7 @@
        blockedStationId: this.parseStationTaskNo(trace.blockedStationId),
        passedStationIds: this.normalizeTraceStationIds(trace.passedStationIds),
        pendingStationIds: this.normalizeTraceStationIds(trace.pendingStationIds),
        latestAppendedPath: this.normalizeTraceStationIds(trace.latestAppendedPath)
        latestAppendedPath: this.normalizeTraceStationIds(trace.latestIssuedSegmentPath || trace.latestAppendedPath)
      };
    },
    normalizeTraceStationIds(list) {
@@ -2794,7 +2794,6 @@
    }
  }
});
src/main/webapp/views/watch/stationTrace.html
New file
@@ -0,0 +1,903 @@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>输送任务轨迹</title>
    <link rel="stylesheet" href="../../static/css/watch/console_vue.css">
    <link rel="stylesheet" href="../../static/vue/element/element.css">
    <style>
        html, body, #app {
            width: 100%;
            height: 100%;
            margin: 0;
            overflow: hidden;
        }
        body {
            background: linear-gradient(180deg, #eef4f8 0%, #e7edf4 100%);
            color: #27425c;
            font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
        }
        #app {
            display: flex;
            flex-direction: column;
        }
        .trace-page {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
            padding: 18px;
            box-sizing: border-box;
            gap: 14px;
        }
        .trace-topbar {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            padding: 14px 18px;
            border-radius: 18px;
            border: 1px solid rgba(255, 255, 255, 0.4);
            background: rgba(248, 251, 253, 0.92);
            box-shadow: 0 10px 24px rgba(88, 110, 136, 0.08);
        }
        .trace-title {
            font-size: 18px;
            font-weight: 700;
            line-height: 1.2;
        }
        .trace-subtitle {
            margin-top: 4px;
            font-size: 12px;
            color: #718399;
        }
        .trace-topbar-actions {
            display: flex;
            align-items: center;
            gap: 10px;
            flex-shrink: 0;
        }
        .trace-search {
            width: 180px;
            height: 34px;
            padding: 0 12px;
            border-radius: 999px;
            border: 1px solid rgba(210, 221, 232, 0.98);
            background: rgba(255, 255, 255, 0.9);
            color: #31485f;
            outline: none;
            box-sizing: border-box;
        }
        .trace-status-pill {
            padding: 6px 10px;
            border-radius: 999px;
            font-size: 11px;
            font-weight: 700;
            background: rgba(111, 149, 189, 0.12);
            color: #42617f;
        }
        .trace-main {
            flex: 1;
            min-height: 0;
            display: grid;
            grid-template-columns: 300px minmax(0, 1fr) 360px;
            gap: 14px;
        }
        .trace-card {
            min-height: 0;
            display: flex;
            flex-direction: column;
            border-radius: 20px;
            border: 1px solid rgba(255, 255, 255, 0.42);
            background: rgba(248, 251, 253, 0.94);
            box-shadow: 0 10px 24px rgba(88, 110, 136, 0.08);
            overflow: hidden;
        }
        .trace-card-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 10px;
            padding: 14px 16px 10px;
            border-bottom: 1px solid rgba(226, 232, 240, 0.72);
            background: rgba(255, 255, 255, 0.24);
        }
        .trace-card-title {
            font-size: 14px;
            font-weight: 700;
            color: #27425c;
        }
        .trace-card-body {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
        }
        .trace-task-list,
        .trace-timeline {
            flex: 1;
            min-height: 0;
            padding: 10px;
            overflow: auto;
        }
        .trace-task-item {
            width: 100%;
            display: flex;
            flex-direction: column;
            gap: 7px;
            margin-bottom: 10px;
            padding: 12px;
            border: 1px solid transparent;
            border-radius: 14px;
            background: rgba(247, 250, 252, 0.82);
            text-align: left;
            color: inherit;
            cursor: pointer;
            box-sizing: border-box;
        }
        .trace-task-item:last-child,
        .trace-event:last-child {
            margin-bottom: 0;
        }
        .trace-task-item.is-active {
            border-color: rgba(111, 149, 189, 0.34);
            background: rgba(235, 243, 251, 0.94);
        }
        .trace-task-line,
        .trace-card-header,
        .trace-event-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 10px;
        }
        .trace-task-title,
        .trace-event-title {
            font-size: 12px;
            font-weight: 700;
            color: #27425c;
        }
        .trace-task-meta,
        .trace-event-detail {
            font-size: 11px;
            color: #748397;
            line-height: 1.5;
            word-break: break-all;
        }
        .trace-badge {
            padding: 3px 8px;
            border-radius: 999px;
            font-size: 10px;
            font-weight: 700;
            white-space: nowrap;
        }
        .trace-badge.is-running {
            background: rgba(59, 130, 246, 0.14);
            color: #245baf;
        }
        .trace-badge.is-rerouted {
            background: rgba(20, 184, 166, 0.16);
            color: #0f766e;
        }
        .trace-badge.is-waiting {
            background: rgba(148, 163, 184, 0.16);
            color: #64748b;
        }
        .trace-badge.is-blocked {
            background: rgba(239, 68, 68, 0.14);
            color: #b42318;
        }
        .trace-badge.is-timeout {
            background: rgba(249, 115, 22, 0.16);
            color: #b45309;
        }
        .trace-badge.is-finished {
            background: rgba(34, 197, 94, 0.14);
            color: #15803d;
        }
        .trace-badge.is-cancelled {
            background: rgba(148, 163, 184, 0.18);
            color: #64748b;
        }
        .trace-map-card .trace-card-body {
            gap: 10px;
            padding: 12px;
            box-sizing: border-box;
            overflow: hidden;
        }
        .trace-detail-scroll {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
            gap: 10px;
            overflow: auto;
            padding-right: 4px;
            box-sizing: border-box;
        }
        .trace-summary-grid {
            display: grid;
            grid-template-columns: repeat(4, minmax(0, 1fr));
            gap: 8px;
        }
        .trace-summary-item,
        .trace-path-row,
        .trace-segment-strip {
            padding: 10px 12px;
            border-radius: 12px;
            background: rgba(247, 250, 252, 0.88);
            border: 1px solid rgba(233, 239, 244, 0.96);
        }
        .trace-summary-label,
        .trace-path-label,
        .trace-segment-head {
            font-size: 11px;
            font-weight: 700;
            color: #6f8194;
        }
        .trace-summary-value {
            margin-top: 5px;
            font-size: 14px;
            font-weight: 700;
            color: #31485f;
            word-break: break-all;
        }
        .trace-path-board {
            display: grid;
            grid-template-columns: 1fr;
            gap: 8px;
        }
        .trace-path-value {
            margin-top: 6px;
            font-size: 12px;
            line-height: 1.5;
            color: #31485f;
            word-break: break-all;
        }
        .trace-segment-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 8px;
        }
        .trace-segment-title {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
            font-size: 12px;
            font-weight: 700;
            color: #31485f;
        }
        .trace-segment-path {
            margin-top: 6px;
            font-size: 11px;
            line-height: 1.5;
            color: #6f8194;
            word-break: break-all;
        }
        .trace-map-shell {
            flex: 0 0 320px;
            min-height: 320px;
            border-radius: 16px;
            overflow: hidden;
            border: 1px solid rgba(224, 232, 239, 0.92);
            background: rgba(255, 255, 255, 0.62);
        }
        .trace-map-shell map-canvas {
            display: block;
            width: 100%;
            height: 100%;
        }
        .trace-event {
            position: relative;
            padding: 0 0 14px 18px;
            margin-bottom: 14px;
            border-left: 2px solid rgba(210, 221, 232, 0.96);
        }
        .trace-event::before {
            content: "";
            position: absolute;
            left: -6px;
            top: 2px;
            width: 10px;
            height: 10px;
            border-radius: 50%;
            background: #6f95bd;
            box-shadow: 0 0 0 3px rgba(111, 149, 189, 0.12);
        }
        .trace-event-time {
            font-size: 11px;
            color: #8090a2;
            white-space: nowrap;
        }
        .trace-event-message,
        .trace-empty {
            font-size: 12px;
            line-height: 1.5;
            color: #4a627a;
        }
        .trace-empty {
            flex: 1;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
            text-align: center;
            color: #8b9aad;
        }
        @media (max-width: 1440px) {
            .trace-main {
                grid-template-columns: 280px minmax(0, 1fr) 320px;
            }
            .trace-summary-grid,
            .trace-segment-grid {
                grid-template-columns: repeat(2, minmax(0, 1fr));
            }
        }
    </style>
    <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
    <script type="text/javascript" src="../../static/js/common.js"></script>
    <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
    <script type="text/javascript" src="../../static/vue/element/element.js"></script>
    <script src="../../static/js/gsap.min.js"></script>
    <script src="../../static/js/pixi-legacy.min.js"></script>
</head>
<body>
<div id="app">
    <div class="trace-page">
        <div class="trace-topbar">
            <div>
                <div class="trace-title">输送任务轨迹</div>
                <div class="trace-subtitle">按任务号查看整条路径、已下发段、当前位置、剩余路径和执行时间线</div>
            </div>
            <div class="trace-topbar-actions">
                <div class="trace-status-pill">{{ wsStatusText }}</div>
                <input class="trace-search" v-model.trim="searchTaskNo" placeholder="筛选任务号" />
            </div>
        </div>
        <div class="trace-main">
            <div class="trace-card">
                <div class="trace-card-header">
                    <div class="trace-card-title">轨迹任务</div>
                    <div class="trace-status-pill">{{ filteredTraces.length }} 个</div>
                </div>
                <div class="trace-card-body">
                    <div v-if="filteredTraces.length === 0" class="trace-empty">当前没有输送任务轨迹</div>
                    <div v-else class="trace-task-list">
                        <button
                                v-for="item in filteredTraces"
                                :key="item.taskNo + '-' + item.traceVersion"
                                type="button"
                                class="trace-task-item"
                                :class="{ 'is-active': selectedTaskNo === item.taskNo }"
                                @click="selectTrace(item.taskNo)">
                            <div class="trace-task-line">
                                <div class="trace-task-title">任务 {{ item.taskNo }}</div>
                                <span class="trace-badge" :class="'is-' + statusTone(item.status)">{{ item.status || '--' }}</span>
                            </div>
                            <div class="trace-task-meta">
                                当前站: {{ orDash(item.currentStationId) }}<br>
                                目标站: {{ orDash(item.finalTargetStationId) }}<br>
                                分段: {{ orDash(item.issuedSegmentCount) }} / {{ orDash(item.totalSegmentCount) }}<br>
                                更新时间: {{ formatTime(item.updatedAt) }}
                            </div>
                        </button>
                    </div>
                </div>
            </div>
            <div class="trace-card trace-map-card">
                <div class="trace-card-header">
                    <div class="trace-card-title">地图与路径摘要</div>
                    <div v-if="selectedTrace" class="trace-status-pill">楼层 {{ currentLev }}F</div>
                </div>
                <div class="trace-card-body">
                    <div class="trace-map-shell">
                        <map-canvas
                                :lev="currentLev"
                                :lev-list="levList"
                                :crn-param="crnParam"
                                :rgv-param="rgvParam"
                                :devp-param="devpParam"
                                :station-task-range="stationTaskRange"
                                :trace-overlay="selectedTrace"
                                @switch-lev="switchLev">
                        </map-canvas>
                    </div>
                    <template v-if="selectedTrace">
                        <div class="trace-detail-scroll">
                            <div class="trace-summary-grid">
                                <div class="trace-summary-item">
                                    <div class="trace-summary-label">任务号</div>
                                    <div class="trace-summary-value">{{ selectedTrace.taskNo }}</div>
                                </div>
                                <div class="trace-summary-item">
                                    <div class="trace-summary-label">状态</div>
                                    <div class="trace-summary-value">{{ orDash(selectedTrace.status) }}</div>
                                </div>
                                <div class="trace-summary-item">
                                    <div class="trace-summary-label">当前站点</div>
                                    <div class="trace-summary-value">{{ orDash(selectedTrace.currentStationId) }}</div>
                                </div>
                                <div class="trace-summary-item">
                                    <div class="trace-summary-label">最终目标</div>
                                    <div class="trace-summary-value">{{ orDash(selectedTrace.finalTargetStationId) }}</div>
                                </div>
                                <div class="trace-summary-item">
                                    <div class="trace-summary-label">已下发段</div>
                                    <div class="trace-summary-value">{{ orDash(selectedTrace.issuedSegmentCount) }}</div>
                                </div>
                                <div class="trace-summary-item">
                                    <div class="trace-summary-label">总段数</div>
                                    <div class="trace-summary-value">{{ orDash(selectedTrace.totalSegmentCount) }}</div>
                                </div>
                                <div class="trace-summary-item">
                                    <div class="trace-summary-label">轨迹版本</div>
                                    <div class="trace-summary-value">{{ orDash(selectedTrace.traceVersion) }}</div>
                                </div>
                                <div class="trace-summary-item">
                                    <div class="trace-summary-label">线程实现</div>
                                    <div class="trace-summary-value">{{ orDash(selectedTrace.threadImpl) }}</div>
                                </div>
                            </div>
                            <div class="trace-path-board">
                                <div class="trace-path-row">
                                    <div class="trace-path-label">完整路径</div>
                                    <div class="trace-path-value">{{ formatPath(selectedTrace.fullPathStationIds) }}</div>
                                </div>
                                <div class="trace-path-row">
                                    <div class="trace-path-label">已下发路径</div>
                                    <div class="trace-path-value">{{ formatPath(selectedTrace.issuedStationIds) }}</div>
                                </div>
                                <div class="trace-path-row">
                                    <div class="trace-path-label">已走路径</div>
                                    <div class="trace-path-value">{{ formatPath(selectedTrace.passedStationIds) }}</div>
                                </div>
                                <div class="trace-path-row">
                                    <div class="trace-path-label">待走路径</div>
                                    <div class="trace-path-value">{{ formatPath(selectedTrace.pendingStationIds) }}</div>
                                </div>
                                <div class="trace-path-row">
                                    <div class="trace-path-label">最新下发段</div>
                                    <div class="trace-path-value">{{ formatPath(selectedTrace.latestIssuedSegmentPath) }}</div>
                                </div>
                            </div>
                            <div class="trace-segment-grid" v-if="selectedSegments.length">
                                <div v-for="segment in selectedSegments" :key="'segment-' + segment.segmentNo + '-' + segment.segmentStartIndex" class="trace-segment-strip">
                                    <div class="trace-segment-title">
                                        <span>第 {{ segment.segmentNo }} 段</span>
                                        <span class="trace-badge" :class="segment.issued ? 'is-finished' : 'is-waiting'">{{ segment.issued ? '已下发' : '待下发' }}</span>
                                    </div>
                                    <div class="trace-segment-path">{{ formatPath(segment.segmentPath) }}</div>
                                </div>
                            </div>
                        </div>
                    </template>
                    <div v-else class="trace-empty">请选择一个任务查看明细</div>
                </div>
            </div>
            <div class="trace-card">
                <div class="trace-card-header">
                    <div class="trace-card-title">执行时间线</div>
                    <div v-if="selectedTrace" class="trace-status-pill">{{ selectedTraceEvents.length }} 条</div>
                </div>
                <div class="trace-card-body">
                    <div v-if="!selectedTrace" class="trace-empty">请选择一个任务查看明细</div>
                    <div v-else-if="selectedTraceEvents.length === 0" class="trace-empty">当前任务还没有轨迹事件</div>
                    <div v-else class="trace-timeline">
                        <div v-for="(event, index) in selectedTraceEvents" :key="event.eventType + '-' + event.timestamp + '-' + index" class="trace-event">
                            <div class="trace-event-head">
                                <div class="trace-event-title">{{ event.eventType }}</div>
                                <div class="trace-event-time">{{ formatTime(event.timestamp) }}</div>
                            </div>
                            <div class="trace-event-message">{{ event.message || '--' }}</div>
                            <div v-for="detail in renderEventDetails(event)" :key="detail" class="trace-event-detail">{{ detail }}</div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
<script src="../../components/MapCanvas.js?v=20260319_station_trace_v1"></script>
<script>
    var stationTraceWs = null;
    new Vue({
        el: '#app',
        data: {
            traces: [],
            selectedTaskNo: null,
            searchTaskNo: '',
            currentLev: 1,
            levList: [],
            stationTaskRange: {
                inbound: null,
                outbound: null
            },
            crnParam: { crnNo: 0 },
            rgvParam: { rgvNo: 0 },
            devpParam: { stationId: 0 },
            wsReconnectTimer: null,
            wsReconnectAttempts: 0,
            wsReconnectBaseDelay: 1000,
            wsReconnectMaxDelay: 15000,
            tracePollTimer: null,
            wsStatus: 'connecting'
        },
        computed: {
            filteredTraces: function () {
                var keyword = String(this.searchTaskNo || '').trim();
                if (!keyword) {
                    return this.traces;
                }
                return this.traces.filter(function (item) {
                    return String(item.taskNo || '').indexOf(keyword) >= 0;
                });
            },
            selectedTrace: function () {
                if (!this.selectedTaskNo) {
                    return null;
                }
                for (var i = 0; i < this.traces.length; i++) {
                    if (this.traces[i] && this.traces[i].taskNo === this.selectedTaskNo) {
                        return this.traces[i];
                    }
                }
                return null;
            },
            selectedSegments: function () {
                var trace = this.selectedTrace;
                return trace && Array.isArray(trace.segmentList) ? trace.segmentList : [];
            },
            selectedTraceEvents: function () {
                var trace = this.selectedTrace;
                if (!trace || !Array.isArray(trace.events)) {
                    return [];
                }
                return trace.events.slice().sort(function (a, b) {
                    var va = a && a.timestamp ? a.timestamp : 0;
                    var vb = b && b.timestamp ? b.timestamp : 0;
                    return vb - va;
                });
            },
            wsStatusText: function () {
                if (this.wsStatus === 'open') {
                    return '已连接';
                }
                if (this.wsStatus === 'closed') {
                    return '连接断开';
                }
                if (this.wsStatus === 'error') {
                    return '连接异常';
                }
                return '连接中';
            }
        },
        watch: {
            selectedTrace: {
                deep: true,
                immediate: true,
                handler: function (trace) {
                    if (!trace) {
                        this.devpParam.stationId = 0;
                        return;
                    }
                    this.devpParam.stationId = this.resolveFocusStationId(trace);
                    this.applySelectedTraceFloor(trace);
                }
            }
        },
        created: function () {
            this.init();
        },
        beforeDestroy: function () {
            if (this.tracePollTimer) {
                clearInterval(this.tracePollTimer);
                this.tracePollTimer = null;
            }
            if (this.wsReconnectTimer) {
                clearTimeout(this.wsReconnectTimer);
                this.wsReconnectTimer = null;
            }
            if (stationTraceWs && (stationTraceWs.readyState === WebSocket.OPEN || stationTraceWs.readyState === WebSocket.CONNECTING)) {
                try { stationTraceWs.close(); } catch (e) {}
            }
        },
        methods: {
            init: function () {
                this.connectWs();
                this.getLevList();
                this.getStationTaskRange();
            },
            connectWs: function () {
                if (stationTraceWs && (stationTraceWs.readyState === WebSocket.OPEN || stationTraceWs.readyState === WebSocket.CONNECTING)) {
                    return;
                }
                this.wsStatus = 'connecting';
                stationTraceWs = new WebSocket('ws://' + window.location.host + baseUrl + '/console/websocket');
                stationTraceWs.onopen = this.webSocketOnOpen;
                stationTraceWs.onerror = this.webSocketOnError;
                stationTraceWs.onmessage = this.webSocketOnMessage;
                stationTraceWs.onclose = this.webSocketOnClose;
            },
            webSocketOnOpen: function () {
                this.wsStatus = 'open';
                this.wsReconnectAttempts = 0;
                if (this.wsReconnectTimer) {
                    clearTimeout(this.wsReconnectTimer);
                    this.wsReconnectTimer = null;
                }
                this.refreshTrace();
                if (!this.tracePollTimer) {
                    this.tracePollTimer = setInterval(function () {
                        this.refreshTrace();
                    }.bind(this), 1000);
                }
            },
            webSocketOnError: function () {
                this.wsStatus = 'error';
                this.scheduleReconnect();
            },
            webSocketOnClose: function () {
                this.wsStatus = 'closed';
                this.scheduleReconnect();
            },
            webSocketOnMessage: function (event) {
                var result = JSON.parse(event.data);
                if (result.url === '/console/latest/data/station/trace') {
                    this.setTraceList(JSON.parse(result.data));
                }
            },
            scheduleReconnect: function () {
                if (this.wsReconnectTimer) {
                    return;
                }
                var attempt = this.wsReconnectAttempts + 1;
                var jitter = Math.floor(Math.random() * 300);
                var delay = Math.min(this.wsReconnectMaxDelay, this.wsReconnectBaseDelay * Math.pow(2, this.wsReconnectAttempts)) + jitter;
                this.wsReconnectTimer = setTimeout(function () {
                    this.wsReconnectTimer = null;
                    this.wsReconnectAttempts = attempt;
                    this.connectWs();
                }.bind(this), delay);
            },
            sendWs: function (payload) {
                if (stationTraceWs && stationTraceWs.readyState === WebSocket.OPEN) {
                    stationTraceWs.send(payload);
                }
            },
            refreshTrace: function () {
                this.sendWs(JSON.stringify({
                    url: '/console/latest/data/station/trace',
                    data: {}
                }));
            },
            setTraceList: function (res) {
                if (!res) {
                    return;
                }
                if (res.code === 403) {
                    parent.location.href = baseUrl + '/login';
                    return;
                }
                if (res.code !== 200) {
                    return;
                }
                this.traces = Array.isArray(res.data) ? res.data : [];
                if (this.selectedTaskNo != null) {
                    var matched = this.traces.some(function (item) {
                        return item && item.taskNo === this.selectedTaskNo;
                    }.bind(this));
                    if (!matched) {
                        this.selectedTaskNo = this.traces.length > 0 ? this.traces[0].taskNo : null;
                    }
                } else if (this.traces.length > 0) {
                    this.selectedTaskNo = this.traces[0].taskNo;
                }
            },
            selectTrace: function (taskNo) {
                this.selectedTaskNo = taskNo;
            },
            switchLev: function (lev) {
                this.currentLev = lev;
            },
            getLevList: function () {
                $.ajax({
                    url: baseUrl + '/basMap/getLevList',
                    headers: { token: localStorage.getItem('token') },
                    method: 'get',
                    success: function (res) {
                        if (!res || res.code !== 200) {
                            return;
                        }
                        this.levList = res.data || [];
                        if ((!this.currentLev || this.currentLev === 0) && this.levList.length > 0) {
                            this.currentLev = this.levList[0];
                        }
                    }.bind(this)
                });
            },
            getStationTaskRange: function () {
                this.fetchWrkLastnoRange(1, 'inbound');
                this.fetchWrkLastnoRange(101, 'outbound');
            },
            fetchWrkLastnoRange: function (id, key) {
                $.ajax({
                    url: baseUrl + '/wrkLastno/' + id + '/auth',
                    headers: { token: localStorage.getItem('token') },
                    method: 'get',
                    success: function (res) {
                        if (!res || res.code !== 200 || !res.data) {
                            return;
                        }
                        var nextRange = Object.assign({}, this.stationTaskRange);
                        nextRange[key] = {
                            start: res.data.sNo,
                            end: res.data.eNo
                        };
                        this.stationTaskRange = nextRange;
                    }.bind(this)
                });
            },
            resolveFocusStationId: function (trace) {
                if (!trace) {
                    return 0;
                }
                return trace.currentStationId || trace.blockedStationId || trace.startStationId || 0;
            },
            applySelectedTraceFloor: function (trace) {
                var floor = this.resolveTraceFloor(trace);
                if (floor > 0 && floor !== this.currentLev) {
                    this.currentLev = floor;
                }
            },
            resolveTraceFloor: function (trace) {
                if (!trace) {
                    return this.currentLev || 1;
                }
                var stationId = trace.currentStationId || trace.blockedStationId || trace.startStationId;
                if (!stationId) {
                    return this.currentLev || 1;
                }
                var floor = parseInt(String(stationId).charAt(0), 10);
                return isNaN(floor) || floor <= 0 ? (this.currentLev || 1) : floor;
            },
            statusTone: function (status) {
                var value = String(status || '').toUpperCase();
                if (value === 'RUNNING') {
                    return 'running';
                }
                if (value === 'REROUTED') {
                    return 'rerouted';
                }
                if (value === 'BLOCKED') {
                    return 'blocked';
                }
                if (value === 'TIMEOUT') {
                    return 'timeout';
                }
                if (value === 'FINISHED') {
                    return 'finished';
                }
                if (value === 'CANCELLED') {
                    return 'cancelled';
                }
                return 'waiting';
            },
            renderEventDetails: function (event) {
                if (!event || !event.details) {
                    return [];
                }
                var labelMap = {
                    traceVersion: '轨迹版本',
                    segmentNo: '分段号',
                    segmentCount: '总段数',
                    segmentPath: '分段路径',
                    segmentStartIndex: '分段起始索引',
                    segmentEndIndex: '分段结束索引',
                    issuedSegmentCount: '已下发段数',
                    totalSegmentCount: '总段数',
                    fullPathStationIds: '完整路径',
                    issuedStationIds: '已下发路径',
                    passedStationIds: '已走路径',
                    pendingStationIds: '待走路径',
                    currentStationId: '当前站点',
                    blockedStationId: '堵塞站点',
                    timeoutMs: '超时时间',
                    commandStationId: '命令起点',
                    commandTargetStationId: '命令目标',
                    targetStationId: '目标站',
                    stationId: '站点',
                    pathOffset: '历史偏移',
                    reason: '原因'
                };
                var result = [];
                Object.keys(event.details).forEach(function (key) {
                    var value = event.details[key];
                    if (value == null || value === '') {
                        return;
                    }
                    var text = Array.isArray(value) ? value.join(' -> ') : String(value);
                    result.push((labelMap[key] || key) + ': ' + text);
                });
                return result;
            },
            formatPath: function (path) {
                if (!Array.isArray(path) || path.length === 0) {
                    return '--';
                }
                return path.join(' -> ');
            },
            formatTime: function (timestamp) {
                if (!timestamp) {
                    return '--';
                }
                var date = new Date(timestamp);
                var pad = function (value) {
                    return value < 10 ? '0' + value : '' + value;
                };
                return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
            },
            orDash: function (value) {
                return value == null || value === '' ? '--' : value;
            }
        }
    });
</script>
</body>
</html>