#
Junjie
8 天以前 ce13e25ed685ba5c961832d023ceafecf4f30d47
#
8个文件已修改
1673 ■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/DeviceLogController.java 219 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/DevpCard.js 46 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchCrnCard.js 33 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchDualCrnCard.js 33 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchRgvCard.js 33 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/deviceLogs/deviceLogs.js 1039 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/deviceLogs/deviceLogs.html 257 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/index.html 13 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/DeviceLogController.java
@@ -1,24 +1,29 @@
package com.zy.asrs.controller;
import com.alibaba.fastjson.JSON;
import com.core.annotations.ManagerAuth;
import com.core.common.Cools;
import com.core.common.R;
import com.zy.asrs.entity.DeviceDataLog;
import com.zy.common.web.BaseController;
import com.zy.core.enums.SlaveType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
@RestController
public class DeviceLogController extends BaseController {
@@ -130,6 +135,186 @@
            return R.ok(res);
        } catch (Exception e) {
            return R.error("读取设备列表失败");
        }
    }
    @RequestMapping(value = "/deviceLog/day/{day}/preview/auth")
    @ManagerAuth
    public R preview(@PathVariable("day") String day,
                     @RequestParam("type") String type,
                     @RequestParam("deviceNo") String deviceNo,
                     @RequestParam(value = "offset", required = false) Integer offset,
                     @RequestParam(value = "limit", required = false) Integer limit) {
        try {
            String dayClean = day == null ? null : day.replaceAll("\\D", "");
            if (dayClean == null || dayClean.length() != 8 || !dayClean.chars().allMatch(Character::isDigit)) {
                return R.error("日期格式错误");
            }
            if (type == null || SlaveType.findInstance(type) == null) {
                return R.error("设备类型错误");
            }
            if (deviceNo == null || !deviceNo.chars().allMatch(Character::isDigit)) {
                return R.error("设备编号错误");
            }
            Path dayDir = Paths.get(loggingPath, dayClean);
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.ok(new ArrayList<>());
            }
            String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
            List<Path> files = Files.list(dayDir)
                    .filter(p -> {
                        String name = p.getFileName().toString();
                        return name.endsWith(".log") && name.startsWith(prefix);
                    }).collect(Collectors.toList());
            files.sort(Comparator.comparingInt(p -> {
                String n = p.getFileName().toString();
                try {
                    String suf = n.substring(prefix.length(), n.length() - 4);
                    return Integer.parseInt(suf);
                } catch (Exception e) {
                    return Integer.MAX_VALUE;
                }
            }));
            int from = offset == null || offset < 0 ? 0 : offset;
            int max = limit == null || limit <= 0 ? 5 : limit; // 默认读取5个文件
            if (max > 10) max = 10; // 限制最大文件数,防止超时
            int to = Math.min(files.size(), from + max);
            if (from >= files.size()) {
                return R.ok(new ArrayList<>());
            }
            List<Path> targetFiles = files.subList(from, to);
            List<DeviceDataLog> resultLogs = new ArrayList<>();
            for (Path f : targetFiles) {
                try (Stream<String> lines = Files.lines(f, StandardCharsets.UTF_8)) {
                    lines.forEach(line -> {
                        if (line != null && !line.trim().isEmpty()) {
                            try {
                                DeviceDataLog logItem = JSON.parseObject(line, DeviceDataLog.class);
                                resultLogs.add(logItem);
                            } catch (Exception e) {
                                // 忽略解析错误
                            }
                        }
                    });
                } catch (Exception e) {
                    log.error("读取日志文件失败: " + f, e);
                }
            }
            // 按时间排序
            resultLogs.sort(Comparator.comparing(DeviceDataLog::getCreateTime, Comparator.nullsLast(Date::compareTo)));
            return R.ok(resultLogs);
        } catch (Exception e) {
            log.error("预览日志失败", e);
            return R.error("预览日志失败");
        }
    }
    @RequestMapping(value = "/deviceLog/day/{day}/seek/auth")
    @ManagerAuth
    public R seek(@PathVariable("day") String day,
                  @RequestParam("type") String type,
                  @RequestParam("deviceNo") String deviceNo,
                  @RequestParam("timestamp") Long timestamp) {
        try {
            String dayClean = day == null ? null : day.replaceAll("\\D", "");
            if (dayClean == null || dayClean.length() != 8 || !dayClean.chars().allMatch(Character::isDigit)) {
                return R.error("日期格式错误");
            }
            if (type == null || SlaveType.findInstance(type) == null) {
                return R.error("设备类型错误");
            }
            if (deviceNo == null || !deviceNo.chars().allMatch(Character::isDigit)) {
                return R.error("设备编号错误");
            }
            Path dayDir = Paths.get(loggingPath, dayClean);
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.error("未找到日志文件");
            }
            String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
            List<Path> files = Files.list(dayDir)
                    .filter(p -> {
                        String name = p.getFileName().toString();
                        return name.endsWith(".log") && name.startsWith(prefix);
                    }).collect(Collectors.toList());
            files.sort(Comparator.comparingInt(p -> {
                String n = p.getFileName().toString();
                try {
                    String suf = n.substring(prefix.length(), n.length() - 4);
                    return Integer.parseInt(suf);
                } catch (Exception e) {
                    return Integer.MAX_VALUE;
                }
            }));
            if (files.isEmpty()) {
                return R.error("未找到日志文件");
            }
            // Binary search for the file containing the timestamp
            // We want to find the LAST file that has startTime <= targetTime.
            // Because files are sequential: [t0, t1), [t1, t2), ...
            // If we find file[i].startTime <= target < file[i+1].startTime, then target is in file[i].
            int low = 0;
            int high = files.size() - 1;
            int foundIndex = -1;
            while (low <= high) {
                int mid = (low + high) >>> 1;
                Path midFile = files.get(mid);
                // Read start time of this file
                Long midStart = getFileStartTime(midFile);
                if (midStart == null) {
                    low = mid + 1;
                    continue;
                }
                if (midStart <= timestamp) {
                    // This file starts before or at target. It COULD be the one.
                    // But maybe a later file also starts before target?
                    foundIndex = mid;
                    low = mid + 1; // Try to find a later start time
                } else {
                    // This file starts AFTER target. So target must be in an earlier file.
                    high = mid - 1;
                }
            }
            if (foundIndex == -1) {
                foundIndex = 0;
            }
            // Return the file index (offset)
            Map<String, Object> result = new HashMap<>();
            result.put("offset", foundIndex);
            return R.ok(result);
        } catch (Exception e) {
            log.error("寻址失败", e);
            return R.error("寻址失败");
        }
    }
    private Long getFileStartTime(Path file) {
        try {
            String firstLine = null;
            try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
                firstLine = lines.findFirst().orElse(null);
            }
            if (firstLine == null) return null;
            DeviceDataLog firstLog = JSON.parseObject(firstLine, DeviceDataLog.class);
            return firstLog.getCreateTime().getTime();
        } catch (Exception e) {
            return null;
        }
    }
@@ -335,4 +520,36 @@
        res.put("finished", info.finished);
        return R.ok(res);
    }
    @RequestMapping(value = "/deviceLog/enums/auth")
    @ManagerAuth
    public R getEnums() {
        Map<String, Map<String, String>> enums = new HashMap<>();
        enums.put("CrnModeType", Arrays.stream(com.zy.core.enums.CrnModeType.values())
                .collect(Collectors.toMap(e -> String.valueOf(e.id), e -> e.desc)));
        enums.put("CrnStatusType", Arrays.stream(com.zy.core.enums.CrnStatusType.values())
                .collect(Collectors.toMap(e -> String.valueOf(e.id), e -> e.desc)));
        enums.put("CrnForkPosType", Arrays.stream(com.zy.core.enums.CrnForkPosType.values())
                .collect(Collectors.toMap(e -> String.valueOf(e.id), e -> e.desc)));
        enums.put("CrnLiftPosType", Arrays.stream(com.zy.core.enums.CrnLiftPosType.values())
                .collect(Collectors.toMap(e -> String.valueOf(e.id), e -> e.desc)));
        enums.put("DualCrnForkPosType", Arrays.stream(com.zy.core.enums.DualCrnForkPosType.values())
                .collect(Collectors.toMap(e -> String.valueOf(e.id), e -> e.desc)));
        enums.put("DualCrnLiftPosType", Arrays.stream(com.zy.core.enums.DualCrnLiftPosType.values())
                .collect(Collectors.toMap(e -> String.valueOf(e.id), e -> e.desc)));
        enums.put("RgvModeType", Arrays.stream(com.zy.core.enums.RgvModeType.values())
                .collect(Collectors.toMap(e -> String.valueOf(e.id), e -> e.desc)));
        enums.put("RgvStatusType", Arrays.stream(com.zy.core.enums.RgvStatusType.values())
                .collect(Collectors.toMap(e -> String.valueOf(e.id), e -> e.desc)));
        return R.ok(enums);
    }
}
src/main/webapp/components/DevpCard.js
@@ -5,7 +5,7 @@
            <div style="width: 100%;">输送监控</div>
            <div style="width: 100%;text-align: right;display: flex;"><el-input size="mini" v-model="searchStationId" placeholder="请输入站号"></el-input><el-button @click="getDevpStateInfo" size="mini">查询</el-button></div>
        </div>
        <div style="margin-bottom: 10px;">
        <div style="margin-bottom: 10px;" v-if="!readOnly">
            <div style="margin-bottom: 5px;">
               <el-button v-if="showControl" @click="openControl" size="mini">关闭控制中心</el-button>
               <el-button v-else @click="openControl" size="mini">打开控制中心</el-button>
@@ -65,10 +65,24 @@
        </div>
    </div>
    `,
  props: ["param"],
  props: {
    param: {
      type: Object,
      default: () => ({})
    },
    autoRefresh: {
      type: Boolean,
      default: true
    },
    readOnly: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      stationList: [],
      fullStationList: [],
      activeNames: "",
      searchStationId: "",
      showControl: false,
@@ -79,12 +93,20 @@
      },
      pageSize: 25,
      currentPage: 1,
      timer: null
    };
  },
  created() {
    setInterval(() => {
      this.getDevpStateInfo();
    }, 1000);
    if (this.autoRefresh) {
      this.timer = setInterval(() => {
        this.getDevpStateInfo();
      }, 1000);
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    displayStationList() {
@@ -96,7 +118,7 @@
  watch: {
    param: {
      handler(newVal, oldVal) {
        if (newVal.stationId != 0) {
        if (newVal && newVal.stationId && newVal.stationId != 0) {
          this.activeNames = newVal.stationId;
          this.searchStationId = newVal.stationId;
        }
@@ -114,7 +136,15 @@
      this.currentPage = 1;
    },
    getDevpStateInfo() {
      if (this.$root.sendWs) {
      if (this.readOnly) {
          // Frontend filtering for readOnly mode
          if (this.searchStationId == "") {
              this.stationList = this.fullStationList;
          } else {
              this.stationList = this.fullStationList.filter(item => item.stationId == this.searchStationId);
              this.currentPage = 1;
          }
      } else if (this.$root.sendWs) {
        this.$root.sendWs(JSON.stringify({
          "url": "/console/latest/data/station",
          "data": {}
@@ -125,7 +155,7 @@
      let that = this;
      if (res.code == 200) {
        let list = res.data;
        that.fullStationList = list;
        if (that.searchStationId == "") {
          that.stationList = list;
        } else {
src/main/webapp/components/WatchCrnCard.js
@@ -5,7 +5,7 @@
            <div style="width: 100%;">堆垛机监控</div>
            <div style="width: 100%;text-align: right;display: flex;"><el-input size="mini" v-model="searchCrnNo" placeholder="请输入堆垛机号"></el-input><el-button @click="getCrnStateInfo" size="mini">查询</el-button></div>
        </div>
        <div style="margin-bottom: 10px;">
        <div style="margin-bottom: 10px;" v-if="!readOnly">
            <div style="margin-bottom: 5px;">
               <el-button v-if="showControl" @click="openControl" size="mini">关闭控制中心</el-button>
               <el-button v-else @click="openControl" size="mini">打开控制中心</el-button>
@@ -75,7 +75,20 @@
        </div>
    </div>
    `,
  props: ["param"],
  props: {
    param: {
      type: Object,
      default: () => ({})
    },
    autoRefresh: {
      type: Boolean,
      default: true
    },
    readOnly: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      crnList: [],
@@ -89,12 +102,20 @@
      },
      pageSize: 25,
      currentPage: 1,
      timer: null
    };
  },
  created() {
    setInterval(() => {
      this.getCrnStateInfo();
    }, 1000);
    if (this.autoRefresh) {
      this.timer = setInterval(() => {
        this.getCrnStateInfo();
      }, 1000);
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    displayCrnList() {
@@ -106,7 +127,7 @@
  watch: {
    param: {
      handler(newVal, oldVal) {
        if (newVal.crnNo != 0) {
        if (newVal && newVal.crnNo && newVal.crnNo != 0) {
          this.activeNames = newVal.crnNo;
          this.searchCrnNo = newVal.crnNo;
          const idx = this.crnList.findIndex(i => i.crnNo == newVal.crnNo);
src/main/webapp/components/WatchDualCrnCard.js
@@ -8,7 +8,7 @@
              <el-button @click="getDualCrnStateInfo" size="mini">查询</el-button>
            </div>
        </div>
        <div style="margin-bottom: 10px;">
        <div style="margin-bottom: 10px;" v-if="!readOnly">
          <div style="margin-bottom: 5px;">
            <el-button v-if="showControl" @click="openControl" size="mini">关闭控制中心</el-button>
            <el-button v-else @click="openControl" size="mini">打开控制中心</el-button>
@@ -86,7 +86,20 @@
        </div>
    </div>
  `,
  props: ["param"],
  props: {
    param: {
      type: Object,
      default: () => ({})
    },
    autoRefresh: {
      type: Boolean,
      default: true
    },
    readOnly: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      crnList: [],
@@ -101,12 +114,20 @@
      },
      pageSize: 25,
      currentPage: 1,
      timer: null
    };
  },
  created() {
    setInterval(() => {
      this.getDualCrnStateInfo();
    }, 1000);
    if (this.autoRefresh) {
      this.timer = setInterval(() => {
        this.getDualCrnStateInfo();
      }, 1000);
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    displayCrnList() {
@@ -118,7 +139,7 @@
  watch: {
    param: {
      handler(newVal) {
        if (newVal.crnNo != 0) {
        if (newVal && newVal.crnNo && newVal.crnNo != 0) {
          this.activeNames = newVal.crnNo;
          this.searchCrnNo = newVal.crnNo;
          const idx = this.crnList.findIndex(i => i.crnNo == newVal.crnNo);
src/main/webapp/components/WatchRgvCard.js
@@ -8,7 +8,7 @@
              <el-button @click="getRgvStateInfo" size="mini">查询</el-button>
            </div>
        </div>
        <div style="margin-bottom: 10px;">
        <div style="margin-bottom: 10px;" v-if="!readOnly">
            <div style="margin-bottom: 5px;">
               <el-button v-if="showControl" @click="openControl" size="mini">关闭控制中心</el-button>
               <el-button v-else @click="openControl" size="mini">打开控制中心</el-button>
@@ -63,7 +63,20 @@
        </div>
    </div>
    `,
  props: ["param"],
  props: {
    param: {
      type: Object,
      default: () => ({})
    },
    autoRefresh: {
      type: Boolean,
      default: true
    },
    readOnly: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      rgvList: [],
@@ -77,12 +90,20 @@
      },
      pageSize: 25,
      currentPage: 1,
      timer: null
    };
  },
  created() {
    setInterval(() => {
      this.getRgvStateInfo();
    }, 1000);
    if (this.autoRefresh) {
      this.timer = setInterval(() => {
        this.getRgvStateInfo();
      }, 1000);
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  computed: {
    displayRgvList() {
@@ -94,7 +115,7 @@
  watch: {
    param: {
      handler(newVal) {
        if (newVal && newVal.rgvNo != 0) {
        if (newVal && newVal.rgvNo && newVal.rgvNo != 0) {
          this.activeNames = newVal.rgvNo;
          const idx = this.rgvList.findIndex(i => i.rgvNo == newVal.rgvNo);
          if (idx >= 0) { this.currentPage = Math.floor(idx / this.pageSize) + 1; }
src/main/webapp/static/js/deviceLogs/deviceLogs.js
@@ -1,228 +1,845 @@
layui.use(['tree', 'layer', 'form', 'element'], function() {
    var tree = layui.tree;
    var $ = layui.jquery;
    var layer = layui.layer;
    var form = layui.form;
    var element = layui.element;
var app = new Vue({
    el: '#app',
    data: {
        // Sidebar Data
        dateTreeData: [],
        defaultProps: {
            children: 'children',
            label: 'title'
        },
        defaultExpandedKeys: [],
    var currentDay = null;
        // Search & List Data
        searchForm: {
            day: '',
            type: '',
            deviceNo: '',
            offset: 0,
            limit: 200
        },
        deviceList: [],
        loading: false,
    function buildMonthTree(data) {
        var monthMap = {};
        (data || []).forEach(function (y) {
            (y.children || []).forEach(function (m) {
                var month = m.title;
                var arr = monthMap[month] || (monthMap[month] = []);
                (m.children || []).forEach(function (d) {
                    arr.push({ title: d.title, id: d.id });
        // Enums
        deviceEnums: {},
        // Visualization State
        visualizationVisible: false,
        visDeviceType: '',
        visDeviceNo: '',
        logs: [],
        isPlaying: false,
        playbackSpeed: 1,
        sliderValue: 0,
        startTime: 0,
        endTime: 0,
        timer: null,
        currentTime: 0,
        lastTick: 0,
        // Jump Time
        jumpVisible: false,
        jumpTime: null,
        seekTargetTime: 0, // Target time we are trying to reach via loading
        seekingOffset: false,
        needToSeekOffset: false,
        // Download State
        downloadDialogVisible: false,
        buildProgress: 0,
        receiveProgress: 0,
        downloadTimer: null
    },
    computed: {
        filteredDeviceList() {
            // Currently just returns the full list loaded for the day
            return this.deviceList;
        },
        visualizationTitle() {
            return `日志可视化 - ${this.visDeviceType} ${this.visDeviceNo} (${this.searchForm.day})`;
        },
        maxSliderValue() {
            return Math.max(0, this.endTime - this.startTime);
        },
        currentTimeStr() {
             if (!this.currentTime) return '';
             var d = new Date(this.currentTime);
             var Y = d.getFullYear() + '-';
             var M = (d.getMonth() + 1 < 10 ? '0' + (d.getMonth() + 1) : d.getMonth() + 1) + '-';
             var D = (d.getDate() < 10 ? '0' + d.getDate() : d.getDate()) + ' ';
             var h = d.getHours().toString().padStart(2, '0');
             var m = d.getMinutes().toString().padStart(2, '0');
             var s = d.getSeconds().toString().padStart(2, '0');
             var ms = d.getMilliseconds().toString().padStart(3, '0');
             return Y + M + D + h + ':' + m + ':' + s + '.' + ms;
        },
        canDownload() {
            return this.searchForm.day && this.searchForm.type && this.searchForm.deviceNo;
        }
    },
    created() {
        this.loadDeviceEnums();
        this.loadDateTree();
    },
    methods: {
        // --- Initialization ---
        loadDeviceEnums() {
            let that = this;
            $.ajax({
                url: baseUrl + "/deviceLog/enums/auth",
                headers: {'token': localStorage.getItem('token')},
                method: 'GET',
                success: function (res) {
                    if (res.code === 200) {
                        that.deviceEnums = res.data || {};
                    }
                }
            });
        },
        // --- Date Tree ---
        loadDateTree() {
            let that = this;
            $.ajax({
                url: baseUrl + "/deviceLog/dates/auth",
                headers: {'token': localStorage.getItem('token')},
                method: 'GET',
                success: function (res) {
                    if (res.code === 200) {
                        that.dateTreeData = that.buildMonthTree(res.data);
                        // Auto-expand current year/month if needed, or just root
                        if (that.dateTreeData.length > 0) {
                            that.defaultExpandedKeys = [that.dateTreeData[0].id];
                        }
                    } else if (res.code === 403) {
                        top.location.href = baseUrl + "/";
                    } else {
                        that.$message.error(res.msg || '加载日期失败');
                    }
                }
            });
        },
        buildMonthTree(data) {
            var monthMap = {};
            (data || []).forEach(function (y) {
                (y.children || []).forEach(function (m) {
                    var month = m.title;
                    var arr = monthMap[month] || (monthMap[month] = []);
                    (m.children || []).forEach(function (d) {
                        arr.push({ title: d.title + '日', id: d.id, day: d.id });
                    });
                });
            });
        });
        var result = [];
        Object.keys(monthMap).sort().forEach(function (month) {
            result.push({ title: month + '月', id: month, children: monthMap[month] });
        });
        return result;
    }
    function loadDateTree() {
        $.ajax({
            url: baseUrl + "/deviceLog/dates/auth",
            headers: {'token': localStorage.getItem('token')},
            method: 'GET',
            beforeSend: function () {
                layer.load(1, {shade: [0.1,'#fff']});
            },
            success: function (res) {
                layer.closeAll('loading');
                if (res.code === 200) {
                    var monthTree = buildMonthTree(res.data);
                    tree.render({
                        elem: '#date-tree',
                        id: 'dateTree',
                        data: monthTree,
                        click: function(obj){
                            var node = obj.data;
                            if (node.id && node.id.length === 8) {
                                currentDay = node.id;
                                $('#selected-day').val(currentDay);
                                loadDevices(currentDay);
                            }
                        }
                    });
                } else if (res.code === 403) {
                    top.location.href = baseUrl + "/";
                } else {
                    layer.msg(res.msg || '加载日期失败', {icon: 2});
                }
            var result = [];
            Object.keys(monthMap).sort().reverse().forEach(function (month) {
                result.push({ title: month + '月', id: month, children: monthMap[month] });
            });
            return result;
        },
        handleNodeClick(data) {
            if (data.day && data.day.length === 8) {
                this.searchForm.day = data.day;
                this.loadDevices(data.day);
            }
        });
    }
        },
    function loadDevices(day) {
        $('#device-list').html('');
        $.ajax({
            url: baseUrl + "/deviceLog/day/" + day + "/devices/auth",
            headers: {'token': localStorage.getItem('token')},
            method: 'GET',
            beforeSend: function () {
                layer.load(1, {shade: [0.1,'#fff']});
            },
            success: function (res) {
                layer.closeAll('loading');
                if (res.code === 200) {
                    if (!res.data || res.data.length === 0) {
                        $('#device-list').html('<div class="layui-text">当日未找到设备日志</div>');
        // --- Device List ---
        loadDevices(day) {
            this.loading = true;
            this.deviceList = [];
            let that = this;
            $.ajax({
                url: baseUrl + "/deviceLog/day/" + day + "/devices/auth",
                headers: {'token': localStorage.getItem('token')},
                method: 'GET',
                success: function (res) {
                    that.loading = false;
                    if (res.code === 200) {
                        that.deviceList = res.data || [];
                    } else if (res.code === 403) {
                        top.location.href = baseUrl + "/";
                    } else {
                        that.$message.error(res.msg || '加载设备失败');
                    }
                },
                error: function() {
                    that.loading = false;
                    that.$message.error('请求失败');
                }
            });
        },
        // --- Download ---
        handleBatchDownload() {
            this.doDownload(this.searchForm.day, this.searchForm.type, this.searchForm.deviceNo);
        },
        downloadLog(deviceNo, type) {
            this.doDownload(this.searchForm.day, type, deviceNo);
        },
        doDownload(day, type, deviceNo) {
            if (!day) return this.$message.warning('请先选择日期');
            if (!type) return this.$message.warning('请选择设备类型');
            if (!deviceNo) return this.$message.warning('请输入设备编号');
            let offset = this.searchForm.offset || 0;
            let limit = this.searchForm.limit || 200;
            let that = this;
            $.ajax({
                url: baseUrl + "/deviceLog/download/init/auth",
                headers: {'token': localStorage.getItem('token')},
                method: 'POST',
                data: JSON.stringify({ day: day, type: type, deviceNo: deviceNo, offset: offset, limit: limit }),
                dataType:'json',
                contentType:'application/json;charset=UTF-8',
                success: function (res) {
                    if (res.code !== 200) {
                        that.$message.error(res.msg || '初始化失败');
                        return;
                    }
                    var html = '';
                    res.data.forEach(function(item){
                        var types = item.types || [];
                        var typeBtns = '';
                        types.forEach(function(t){
                            typeBtns += '<button class="layui-btn layui-btn-xs" data-type="' + t + '" data-device-no="' + item.deviceNo + '">下载(' + t + ')</button>';
                        });
                        html += '<div class="layui-col-xs12" style="margin-bottom:8px;">' +
                            '<div class="layui-card">' +
                            '<div class="layui-card-body">' +
                            '<span>设备编号:<b>' + item.deviceNo + '</b></span>' +
                            '<span style="margin-left:20px;">类型:' + types.join(',') + '</span>' +
                            '<span style="margin-left:20px;">文件数:' + item.fileCount + '</span>' +
                            '<span style="margin-left:20px;">' + typeBtns + '</span>' +
                            '</div>' +
                            '</div>' +
                            '</div>';
                    });
                    $('#device-list').html(html);
                } else if (res.code === 403) {
                    top.location.href = baseUrl + "/";
                } else {
                    layer.msg(res.msg || '加载设备失败', {icon: 2});
                    var pid = res.data.progressId;
                    that.startDownloadProgress(pid);
                    that.performDownloadRequest(day, type, deviceNo, offset, limit, pid);
                }
            }
        });
    }
    function downloadDeviceLog(day, type, deviceNo) {
        if (!day) {
            layer.msg('请先选择日期', {icon: 2});
            return;
        }
        if (!type) {
            layer.msg('请选择设备类型', {icon: 2});
            return;
        }
        if (!deviceNo) {
            layer.msg('请输入设备编号', {icon: 2});
            return;
        }
        var offsetVal = parseInt($('#file-offset').val());
        var limitVal = parseInt($('#file-limit').val());
        var offset = isNaN(offsetVal) || offsetVal < 0 ? 0 : offsetVal;
        var limit = isNaN(limitVal) || limitVal <= 0 ? 200 : limitVal;
        $.ajax({
            url: baseUrl + "/deviceLog/download/init/auth",
            headers: {'token': localStorage.getItem('token')},
            method: 'POST',
            data: JSON.stringify({ day: day, type: type, deviceNo: deviceNo, offset: offset, limit: limit }),
            dataType:'json',
            contentType:'application/json;charset=UTF-8',
            success: function (res) {
                if (res.code !== 200) {
                    layer.msg(res.msg || '初始化失败', {icon: 2});
                    return;
                }
                var pid = res.data.progressId;
                var progressIndex = layer.open({
                    type: 1,
                    title: '下载中',
                    area: ['520px', '200px'],
                    content: '<div style="padding:16px;">' +
                        '<div class="layui-text" style="margin-bottom:15px;">压缩生成进度</div>' +
                        '<div class="layui-progress" lay-showPercent="true" lay-filter="buildProgress">' +
                        '<div class="layui-progress-bar" style="width:0%"><span class="layui-progress-text">0%</span></div>' +
                        '</div>' +
                        '<div class="layui-text" style="margin:12px 0 15px;">下载接收进度</div>' +
                        '<div class="layui-progress" lay-showPercent="true" lay-filter="receiveProgress">' +
                        '<div class="layui-progress-bar" style="width:0%"><span class="layui-progress-text">0%</span></div>' +
                        '</div>' +
                        '</div>'
                });
                var timer = setInterval(function(){
                    $.ajax({
                        url: baseUrl + '/deviceLog/download/progress/auth',
                        headers: {'token': localStorage.getItem('token')},
                        method: 'GET',
                        data: { id: pid },
                        success: function (p) {
                            if (p.code === 200) {
                                var percent = p.data.percent || 0;
                                element.progress('buildProgress', percent + '%');
                                // 隐藏实时大小,不更新文字
                            }
                        }
                    });
                }, 500);
            });
        },
        startDownloadProgress(pid) {
            this.downloadDialogVisible = true;
            this.buildProgress = 0;
            this.receiveProgress = 0;
            let that = this;
            this.downloadTimer = setInterval(function(){
                $.ajax({
                    url: baseUrl + "/deviceLog/day/" + day + "/download/auth?type=" + encodeURIComponent(type) + "&deviceNo=" + encodeURIComponent(deviceNo) + "&offset=" + offset + "&limit=" + limit + "&progressId=" + encodeURIComponent(pid),
                    url: baseUrl + '/deviceLog/download/progress/auth',
                    headers: {'token': localStorage.getItem('token')},
                    method: 'GET',
                    xhrFields: { responseType: 'blob' },
                    xhr: function(){
                        var xhr = new window.XMLHttpRequest();
                        xhr.onprogress = function(e){
                            var percent = 0;
                            if (e.lengthComputable && e.total > 0) {
                                percent = Math.floor(e.loaded / e.total * 100);
                                element.progress('receiveProgress', percent + '%');
                            }
                            // 隐藏实时大小,不更新文字
                        };
                        return xhr;
                    },
                    success: function (data, status, xhr) {
                        var disposition = xhr.getResponseHeader('Content-Disposition') || '';
                        var filename = type + '_' + deviceNo + '_' + day + '.zip';
                        var match = /filename=(.+)/.exec(disposition);
                        if (match && match[1]) {
                            filename = decodeURIComponent(match[1]);
                    data: { id: pid },
                    success: function (p) {
                        if (p.code === 200) {
                            var percent = p.data.percent || 0;
                            that.buildProgress = percent;
                        }
                        element.progress('buildProgress', '100%');
                        element.progress('receiveProgress', '100%');
                        var blob = new Blob([data], {type: 'application/zip'});
                        var link = document.createElement('a');
                        var url = window.URL.createObjectURL(blob);
                        link.href = url;
                        link.download = filename;
                        document.body.appendChild(link);
                        link.click();
                        document.body.removeChild(link);
                        window.URL.revokeObjectURL(url);
                        clearInterval(timer);
                        setTimeout(function(){ layer.close(progressIndex); }, 300);
                    },
                    error: function () {
                        clearInterval(timer);
                        layer.close(progressIndex);
                        layer.msg('下载失败或未找到日志', {icon: 2});
                    }
                });
            }, 500);
        },
        performDownloadRequest(day, type, deviceNo, offset, limit, pid) {
            let that = this;
            $.ajax({
                url: baseUrl + "/deviceLog/day/" + day + "/download/auth?type=" + encodeURIComponent(type) + "&deviceNo=" + encodeURIComponent(deviceNo) + "&offset=" + offset + "&limit=" + limit + "&progressId=" + encodeURIComponent(pid),
                headers: {'token': localStorage.getItem('token')},
                method: 'GET',
                xhrFields: { responseType: 'blob' },
                xhr: function(){
                    var xhr = new window.XMLHttpRequest();
                    xhr.onprogress = function(e){
                        if (e.lengthComputable && e.total > 0) {
                            var percent = Math.floor(e.loaded / e.total * 100);
                            that.receiveProgress = percent;
                        }
                    };
                    return xhr;
                },
                success: function (data, status, xhr) {
                    var disposition = xhr.getResponseHeader('Content-Disposition') || '';
                    var filename = type + '_' + deviceNo + '_' + day + '.zip';
                    var match = /filename=(.+)/.exec(disposition);
                    if (match && match[1]) {
                        filename = decodeURIComponent(match[1]);
                    }
                    that.buildProgress = 100;
                    that.receiveProgress = 100;
                    var blob = new Blob([data], {type: 'application/zip'});
                    var link = document.createElement('a');
                    var url = window.URL.createObjectURL(blob);
                    link.href = url;
                    link.download = filename;
                    document.body.appendChild(link);
                    link.click();
                    document.body.removeChild(link);
                    window.URL.revokeObjectURL(url);
                    clearInterval(that.downloadTimer);
                    setTimeout(() => { that.downloadDialogVisible = false; }, 1000);
                },
                error: function () {
                    clearInterval(that.downloadTimer);
                    that.downloadDialogVisible = false;
                    that.$message.error('下载失败或未找到日志');
                }
            });
        },
        // --- Visualization ---
        visualizeLog(deviceNo, type) {
            this.visDeviceType = type;
            this.visDeviceNo = deviceNo;
            this.visOffset = this.searchForm.offset || 0;
            // Optimization: Load fewer files per request to speed up response
            // searchForm.limit might be large (for download), so we force a small batch for visualization
            this.visLimit = 2;
            this.logs = [];
            this.hasMoreLogs = true;
            this.loadingLogs = false;
            this.startTime = 0;
            this.endTime = 0;
            this.currentTime = 0;
            this.sliderValue = 0;
            this.isPlaying = false;
            this.playbackSpeed = 1;
            this.visualizationVisible = true;
            this.loadMoreLogs();
        },
        loadMoreLogs() {
            if (this.loadingLogs || !this.hasMoreLogs) return;
            this.loadingLogs = true;
            // Use Vue loading service if available, or element UI loading
            let loadingInstance = null;
            // Show loading if explicitly seeking (jumping far ahead) or normal load
            if (this.seekTargetTime > 0) {
                 if (this.$loading) {
                    loadingInstance = this.$loading({
                        target: '.vis-container',
                        lock: true,
                        text: '正在跳转至目标时间 (加载中)...',
                        spinner: 'el-icon-loading',
                        background: 'rgba(255, 255, 255, 0.7)'
                    });
                }
            } else if (this.$loading && !this.isPlaying) {
                 loadingInstance = this.$loading({
                     target: '.vis-container',
                     lock: true,
                     text: '加载数据中...',
                     spinner: 'el-icon-loading',
                     background: 'rgba(255, 255, 255, 0.7)'
                 });
            }
        });
            let that = this;
            // If seeking and we have no idea where the target time is in terms of files,
            // we should ask the server for the correct offset first!
            if (this.seekTargetTime > 0 && this.visOffset === (this.searchForm.offset || 0)) {
                 // First time seeking or reset? No, this condition is tricky.
                 // Actually, if we are seeking, we can call the new /seek endpoint first.
                 // BUT, loadMoreLogs is recursive for seek. We need to be careful.
                 // Let's modify logic:
                 // If seekTargetTime is set, and we suspect it's far away (e.g. not in next batch),
                 // we should use the seek endpoint.
                 // For simplicity, let's ALWAYS try seek endpoint if seeking far ahead?
                 // Or just if we are seeking.
                 // However, loadMoreLogs is currently designed to just load NEXT batch.
                 // We should probably intercept the flow here.
            }
            // NEW LOGIC: If seeking, try to find offset first
            if (this.seekTargetTime > 0 && this.needToSeekOffset && !this.seekingOffset) {
                this.seekingOffset = true;
                $.ajax({
                    url: baseUrl + "/deviceLog/day/" + this.searchForm.day + "/seek/auth",
                    headers: {'token': localStorage.getItem('token')},
                    data: { type: this.visDeviceType, deviceNo: this.visDeviceNo, timestamp: this.seekTargetTime },
                    success: function(res) {
                        if (res.code === 200) {
                            var targetOffset = res.data.offset;
                            // Update offset directly
                            that.visOffset = targetOffset;
                            // Clear logs because we jumped
                            that.logs = [];
                            that.seekingOffset = false;
                            that.needToSeekOffset = false;
                            // Now continue to load logs from this new offset
                            // We set seekTargetTime still > 0 so it will check if we arrived.
                            // But we need to call the actual load now.
                            // We recurse (but we need to reset loadingLogs flag first or it returns)
                            // that.loadingLogs = false; // Do not reset loadingLogs here as we are still "loading"
                            // that.loadMoreLogs(); // Recursive call is risky if not careful
                            // Better: call sequential load directly
                            that.loadMoreLogsSequential(loadingInstance);
                        } else {
                            // Fallback to sequential load if seek fails
                            that.seekingOffset = false;
                            that.needToSeekOffset = false;
                            that.loadMoreLogsSequential(loadingInstance);
                        }
                    },
                    error: function() {
                        that.seekingOffset = false;
                        that.needToSeekOffset = false;
                        that.loadMoreLogsSequential(loadingInstance);
                    }
                });
                return;
            }
            this.loadMoreLogsSequential(loadingInstance);
        },
        loadMoreLogsSequential(loadingInstance) {
             let that = this;
             let currentLimit = this.seekTargetTime > 0 ? 10 : this.visLimit;
             $.ajax({
                url: baseUrl + "/deviceLog/day/" + this.searchForm.day + "/preview/auth",
                headers: {'token': localStorage.getItem('token')},
                data: { type: this.visDeviceType, deviceNo: this.visDeviceNo, offset: this.visOffset, limit: currentLimit },
                success: function(res) {
                    if (loadingInstance) loadingInstance.close();
                    that.loadingLogs = false;
                    if (res.code === 200) {
                        var newLogs = res.data || [];
                        if (newLogs.length === 0) {
                            that.hasMoreLogs = false;
                            if (that.seekTargetTime > 0) {
                                that.$message.warning('已到达日志末尾,无法到达目标时间');
                                that.seekTargetTime = 0;
                            } else {
                                if (that.logs.length === 0) that.$message.warning('没有找到日志数据');
                                else that.$message.info('数据已全部加载');
                            }
                            return;
                        }
                        // If we cleared logs (jumped), we need to set start time again maybe?
                        // If logs is empty, it means we jumped or initial load.
                        var isJump = that.logs.length === 0;
                        that.logs = that.logs.concat(newLogs);
                        that.visOffset += currentLimit;
                        if (that.logs.length > 0) {
                            if (isJump) {
                                // If we jumped, we need to ensure we don't break startTime if possible,
                                // OR we update startTime if it was 0.
                                // If we jumped to middle, startTime of the whole day is still 0?
                                // No, startTime usually is the beginning of the visualized session.
                                // If we jump, we might want to keep the "view" consistent?
                                // Actually, if we jump, we effectively discard previous logs.
                                // So the slider range might change?
                                // The user expects slider to represent the WHOLE day?
                                // Currently slider represents [startTime, endTime] of LOADED logs.
                                // If we jump, we might lose the "start".
                                // To support "Whole Day" slider, we need startTime of the FIRST log of the day.
                                // But we don't have that if we jump.
                                // For now, let's just update endTime.
                                // If it's a jump, we might need to adjust startTime if it's the first chunk we have.
                                if (that.startTime === 0) {
                                    that.startTime = new Date(that.logs[0].createTime).getTime();
                                    that.currentTime = that.startTime;
                                    that.$nextTick(() => {
                                        that.updateDeviceState(that.logs[0]);
                                    });
                                }
                            } else {
                                // Normal load (initial or sequential)
                                // If initial load (startTime is 0)
                                if (that.startTime === 0) {
                                    that.startTime = new Date(that.logs[0].createTime).getTime();
                                    that.currentTime = that.startTime;
                                    that.$nextTick(() => {
                                        that.updateDeviceState(that.logs[0]);
                                    });
                                }
                            }
                            // Update end time
                            that.endTime = new Date(that.logs[that.logs.length - 1].createTime).getTime();
                            // Handle Seek Logic
                            if (that.seekTargetTime > 0) {
                                // If we jumped, we should be close.
                                // Check if target is in current range
                                var lastLogTime = new Date(that.logs[that.logs.length - 1].createTime).getTime();
                                if (lastLogTime >= that.seekTargetTime) {
                                    that.currentTime = that.seekTargetTime;
                                    that.sliderValue = that.currentTime - that.startTime;
                                    that.syncState();
                                    that.seekTargetTime = 0;
                                    that.$message.success('已跳转至目标时间');
                                } else {
                                    // Still not there?
                                    // If we used /seek, we should be there or very close.
                                    // Maybe the file we found ends before target?
                                    // We continue loading.
                                    setTimeout(() => {
                                        that.loadMoreLogs();
                                    }, 50);
                                }
                            } else if (isJump) {
                                // If not seeking (just loaded via jump?), but we cleared logs...
                                // Wait, we only clear logs if seekTargetTime > 0 in the new logic.
                                // So this else is for normal load.
                            }
                        }
                    } else {
                        that.$message.error(res.msg);
                        that.seekTargetTime = 0;
                    }
                },
                error: function() {
                    if (loadingInstance) loadingInstance.close();
                    that.loadingLogs = false;
                    that.seekTargetTime = 0;
                    that.$message.error('请求失败');
                }
            });
        },
        handleVisualizationClose() {
            this.pause();
            this.visualizationVisible = false;
        },
        // --- Playback Logic ---
        play() {
            this.isPlaying = true;
            this.lastTick = Date.now();
            this.tick();
        },
        pause() {
            this.isPlaying = false;
            if (this.timer) cancelAnimationFrame(this.timer);
        },
        reset() {
            this.pause();
            this.currentTime = this.startTime;
            this.sliderValue = 0;
            if (this.logs.length > 0) {
                this.updateDeviceState(this.logs[0]);
            }
        },
        tick() {
            if (!this.isPlaying) return;
            var now = Date.now();
            var delta = now - this.lastTick;
            this.lastTick = now;
            // Auto-load more logs if we are close to the end (prefetch)
            if (this.hasMoreLogs && !this.loadingLogs) {
                var idx = this.binarySearch(this.currentTime);
                // If within last 20 frames
                if (this.logs.length > 0 && (this.logs.length - 1 - idx) < 20) {
                     this.loadMoreLogs();
                }
            }
            var nextTime = this.currentTime + delta * this.playbackSpeed;
            if (nextTime >= this.endTime) {
                if (this.hasMoreLogs) {
                    // Reached end of buffer, but more data available
                    // Clamp to endTime
                    nextTime = this.endTime;
                    // Ensure loading is triggered
                    if (!this.loadingLogs) {
                        this.loadMoreLogs();
                    }
                    // Update state but do NOT pause
                    this.currentTime = nextTime;
                    this.sliderValue = this.currentTime - this.startTime;
                    this.syncState();
                    // Continue loop to check again next frame
                    this.timer = requestAnimationFrame(this.tick);
                    return;
                } else {
                    // Truly finished
                    nextTime = this.endTime;
                    this.currentTime = nextTime;
                    this.sliderValue = this.currentTime - this.startTime;
                    this.syncState();
                    this.pause();
                    return;
                }
            }
            this.currentTime = nextTime;
            this.sliderValue = this.currentTime - this.startTime;
            this.syncState();
            this.timer = requestAnimationFrame(this.tick);
        },
        sliderChange(val) {
            this.currentTime = this.startTime + val;
            this.syncState();
            // If dragged near the end, load more
            if (this.hasMoreLogs && !this.loadingLogs) {
                 var idx = this.binarySearch(this.currentTime);
                 if (this.logs.length > 0 && (this.logs.length - 1 - idx) < 20) {
                     this.loadMoreLogs();
                 }
            }
        },
        sliderInput(val) {
            this.currentTime = this.startTime + val;
            this.syncState();
            // If dragged near the end, load more
            if (this.hasMoreLogs && !this.loadingLogs) {
                 var idx = this.binarySearch(this.currentTime);
                 if (this.logs.length > 0 && (this.logs.length - 1 - idx) < 20) {
                     this.loadMoreLogs();
                 }
            }
        },
        syncState() {
            var idx = this.binarySearch(this.currentTime);
            if (idx >= 0) {
                var targetLog = this.logs[idx];
                this.updateDeviceState(targetLog);
            }
        },
        binarySearch(time) {
            let l = 0, r = this.logs.length - 1;
            let ans = -1;
            while (l <= r) {
                let mid = Math.floor((l + r) / 2);
                let logTime = new Date(this.logs[mid].createTime).getTime();
                if (logTime <= time) {
                    ans = mid;
                    l = mid + 1;
                } else {
                    r = mid - 1;
                }
            }
            return ans;
        },
        updateDeviceState(logItem) {
            if (!logItem || !logItem.wcsData) return;
            try {
                var protocol = JSON.parse(logItem.wcsData);
                var list = [];
                if (this.visDeviceType === 'Devp' && Array.isArray(protocol)) {
                    list = protocol.map(p => this.transformData(p, this.visDeviceType));
                    list.sort((a, b) => (a.stationId || 0) - (b.stationId || 0));
                } else {
                    var data = this.transformData(protocol, this.visDeviceType);
                    list = [data];
                }
                var res = { code: 200, data: list };
                if (this.$refs.card) {
                    if (this.visDeviceType === 'Crn') {
                        this.$refs.card.setCrnList(res);
                    } else if (this.visDeviceType === 'Rgv') {
                        this.$refs.card.setRgvList(res);
                    } else if (this.visDeviceType === 'DualCrn') {
                        this.$refs.card.setDualCrnList(res);
                    } else if (this.visDeviceType === 'Devp') {
                        this.$refs.card.setStationList(res);
                    }
                }
            } catch (e) {
                console.error('Error parsing wcsData', e);
            }
        },
        transformData(protocol, type) {
            if (!protocol) return {};
            // Enums from API
            var CrnModeType = this.deviceEnums.CrnModeType || {};
            var CrnStatusType = this.deviceEnums.CrnStatusType || {};
            var CrnForkPosType = this.deviceEnums.CrnForkPosType || {};
            var CrnLiftPosType = this.deviceEnums.CrnLiftPosType || {};
            var DualCrnForkPosType = this.deviceEnums.DualCrnForkPosType || {};
            var DualCrnLiftPosType = this.deviceEnums.DualCrnLiftPosType || {};
            var RgvModeType = this.deviceEnums.RgvModeType || {};
            var RgvStatusType = this.deviceEnums.RgvStatusType || {};
            if (type === 'Crn') {
                return {
                    crnNo: protocol.crnNo,
                    workNo: protocol.taskNo || 0,
                    mode: CrnModeType[protocol.mode] || '-',
                    status: CrnStatusType[protocol.status] || '-',
                    loading: protocol.loaded == 1 ? '有物' : '无物',
                    bay: protocol.bay,
                    lev: protocol.level,
                    forkOffset: CrnForkPosType[protocol.forkPos] || '-',
                    liftPos: CrnLiftPosType[protocol.liftPos] || '-',
                    walkPos: (protocol.walkPos == 1) ? '不在定位' : '在定位',
                    xspeed: protocol.xSpeed || 0,
                    yspeed: protocol.ySpeed || 0,
                    zspeed: protocol.zSpeed || 0,
                    xdistance: protocol.xDistance || 0,
                    ydistance: protocol.yDistance || 0,
                    warnCode: protocol.alarm,
                    deviceStatus: (protocol.alarm && protocol.alarm > 0) ? 'ERROR' :
                                  ((protocol.taskNo && protocol.taskNo > 0) ? 'WORKING' :
                                  (protocol.mode == 3 ? 'AUTO' : 'OFFLINE'))
                };
            } else if (type === 'DualCrn') {
                 var vo = {
                    crnNo: protocol.crnNo,
                    taskNo: protocol.taskNo || 0,
                    taskNoTwo: protocol.taskNoTwo || 0,
                    mode: CrnModeType[protocol.mode] || '-',
                    status: CrnStatusType[protocol.status] || '-',
                    statusTwo: CrnStatusType[protocol.statusTwo] || '-',
                    loading: protocol.loaded == 1 ? '有物' : '无物',
                    loadingTwo: protocol.loadedTwo == 1 ? '有物' : '无物',
                    bay: protocol.bay,
                    lev: protocol.level,
                    forkOffset: DualCrnForkPosType[protocol.forkPos] || '-',
                    forkOffsetTwo: DualCrnForkPosType[protocol.forkPosTwo] || '-',
                    liftPos: DualCrnLiftPosType[protocol.liftPos] || '-',
                    walkPos: protocol.walkPos == 0 ? '在定位' : '不在定位',
                    taskReceive: protocol.taskReceive == 1 ? '接收' : '无任务',
                    taskReceiveTwo: protocol.taskReceiveTwo == 1 ? '接收' : '无任务',
                    xspeed: protocol.xSpeed,
                    yspeed: protocol.ySpeed,
                    zspeed: protocol.zSpeed,
                    xdistance: protocol.xDistance,
                    ydistance: protocol.yDistance,
                    warnCode: protocol.alarm
                 };
                 if (protocol.alarm && protocol.alarm > 0) vo.deviceStatus = 'ERROR';
                 else if ((protocol.taskNo && protocol.taskNo > 0) || (protocol.taskNoTwo && protocol.taskNoTwo > 0)) vo.deviceStatus = 'WORKING';
                 else if (protocol.mode == 3) vo.deviceStatus = 'AUTO';
                 else vo.deviceStatus = 'OFFLINE';
                 return vo;
            } else if (type === 'Rgv') {
                 var vo = {
                     rgvNo: protocol.rgvNo,
                     taskNo: protocol.taskNo,
                     mode: RgvModeType[protocol.mode] || '',
                     status: RgvStatusType[protocol.status] || '',
                     loading: protocol.loaded == 1 ? '有物' : '无物',
                     trackSiteNo: protocol.rgvPos,
                     warnCode: protocol.alarm
                 };
                 var deviceStatus = "";
                 if (protocol.mode == 3) deviceStatus = "AUTO";
                 if (protocol.taskNo && protocol.taskNo > 0) deviceStatus = "WORKING";
                 if (protocol.alarm && protocol.alarm > 0) deviceStatus = "ERROR";
                 vo.deviceStatus = deviceStatus;
                 return vo;
             } else if (type === 'Devp') {
                return {
                    stationId: protocol.stationId,
                    taskNo: protocol.taskNo,
                    targetStaNo: protocol.targetStaNo,
                    autoing: protocol.autoing,
                    loading: protocol.loading,
                    inEnable: protocol.inEnable,
                    outEnable: protocol.outEnable,
                    emptyMk: protocol.emptyMk,
                    fullPlt: protocol.fullPlt,
                    runBlock: protocol.runBlock,
                    enableIn: protocol.enableIn,
                    palletHeight: protocol.palletHeight,
                    barcode: protocol.barcode,
                    weight: protocol.weight,
                    error: protocol.error,
                    errorMsg: protocol.errorMsg,
                    extend: protocol.extend
                };
            }
            return protocol;
        },
        formatTooltip(val) {
            var t = this.startTime + val;
            var d = new Date(t);
            var Y = d.getFullYear() + '-';
            var M = (d.getMonth() + 1 < 10 ? '0' + (d.getMonth() + 1) : d.getMonth() + 1) + '-';
            var D = (d.getDate() < 10 ? '0' + d.getDate() : d.getDate()) + ' ';
            return Y + M + D + d.toLocaleTimeString() + '.' + d.getMilliseconds();
        },
        initJumpTime() {
            if (this.currentTime > 0) {
                this.jumpTime = new Date(this.currentTime);
            } else if (this.startTime > 0) {
                this.jumpTime = new Date(this.startTime);
            } else {
                // Try to parse from searchForm.day
                if (this.searchForm.day && this.searchForm.day.length === 8) {
                    var y = this.searchForm.day.substring(0, 4);
                    var m = this.searchForm.day.substring(4, 6);
                    var d = this.searchForm.day.substring(6, 8);
                    // Default to 00:00:00 of that day
                    this.jumpTime = new Date(y + '/' + m + '/' + d + ' 00:00:00');
                } else {
                    this.jumpTime = new Date();
                }
            }
        },
        confirmJump() {
            if (!this.jumpTime) return;
            // Construct target timestamp
            // jumpTime from el-time-picker is a Date object (if not using value-format)
            // or string/timestamp if using value-format.
            // We didn't set value-format, so it should be Date object (default in ElementUI 2.x?)
            // Actually, in default_api:Read above, I saw:
            // <el-time-picker v-model="jumpTime" ... :picker-options="{ selectableRange: '00:00:00 - 23:59:59' }">
            // Default v-model for el-time-picker is Date object.
            let targetDate = this.jumpTime;
            if (typeof targetDate === 'string' || typeof targetDate === 'number') {
                targetDate = new Date(targetDate);
            }
            let baseDate = new Date(this.startTime > 0 ? this.startTime : Date.now());
            baseDate.setHours(targetDate.getHours());
            baseDate.setMinutes(targetDate.getMinutes());
            baseDate.setSeconds(targetDate.getSeconds());
            // Picker usually 0 ms
            baseDate.setMilliseconds(0);
            let targetTs = baseDate.getTime();
            if (this.startTime > 0 && targetTs < this.startTime) {
                 targetTs = this.startTime;
            }
            // Check if beyond endTime
            if (this.endTime > 0 && targetTs > this.endTime) {
                // If we have more logs, we try to go as far as we can (endTime)
                // and trigger loading
                if (this.hasMoreLogs) {
                    this.seekTargetTime = targetTs;
                    this.needToSeekOffset = true;
                    // Trigger load immediately
                    if (!this.loadingLogs) {
                        this.loadMoreLogs();
                    } else {
                        // Already loading, just set the target and let callback handle it
                    }
                    this.jumpVisible = false;
                    return; // Don't update current time yet, wait for load
                } else {
                    targetTs = this.endTime;
                    this.$message.warning('目标时间超出日志范围,已跳转至结束时间');
                }
            }
            this.currentTime = targetTs;
            this.sliderValue = this.currentTime - this.startTime;
            this.syncState();
            this.jumpVisible = false;
            // Trigger load if needed
            if (this.hasMoreLogs && !this.loadingLogs) {
                 // Force load check
                 this.loadMoreLogs();
            }
        }
    }
    $(document).on('click', '#download-btn', function () {
        downloadDeviceLog(currentDay, $('#device-type-input').val(), $('#device-no-input').val());
    });
    $(document).on('click', '#device-list .layui-btn', function () {
        var deviceNo = $(this).attr('data-device-no');
        var type = $(this).attr('data-type');
        downloadDeviceLog(currentDay, type, deviceNo);
    });
    loadDateTree();
    limit();
});
});
src/main/webapp/views/deviceLogs/deviceLogs.html
@@ -6,91 +6,210 @@
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <link rel="stylesheet" href="../../static/layui/css/layui.css" media="all">
    <link rel="stylesheet" href="../../static/css/admin.css?v=318" media="all">
    <link rel="stylesheet" href="../../static/css/cool.css" media="all">
    <!-- CSS -->
    <link rel="stylesheet" href="../../static/vue/element/element.css">
    <link rel="stylesheet" href="../../static/css/common.css">
    <style>
        body { margin: 0; padding: 0; background-color: #f0f2f5; height: 100vh; overflow: hidden; }
        #app { height: 100%; padding: 10px; box-sizing: border-box; display: flex; flex-direction: column; }
        .main-container { flex: 1; display: flex; overflow: hidden; }
        .sidebar { width: 260px; margin-right: 10px; display: flex; flex-direction: column; }
        .content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
        .box-card { height: 100%; display: flex; flex-direction: column; border: none; box-shadow: 0 1px 4px rgba(0,21,41,.08); }
        .box-card .el-card__header { padding: 10px 15px; border-bottom: 1px solid #ebeef5; background: #fff; font-weight: bold; font-size: 15px; }
        .box-card .el-card__body { flex: 1; overflow: auto; padding: 15px; }
        .device-item { margin-bottom: 10px; }
        .device-card { background-color: #fff; border: 1px solid #e6ebf5; border-radius: 4px; transition: all .3s; }
        .device-card:hover { box-shadow: 0 2px 12px 0 rgba(0,0,0,.1); }
        .device-info { display: flex; justify-content: space-between; align-items: center; padding: 15px; }
        .device-info .info-text { font-size: 14px; color: #606266; }
        .device-info .info-text b { color: #303133; margin-right: 5px; }
        .device-info .tag-group { margin-left: 15px; }
        .control-bar { margin-bottom: 15px; padding: 15px; background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,21,41,.08); }
        /* Visualization styles */
        .vis-control-panel { margin-bottom: 10px; display: flex; align-items: center; background: #f5f7fa; padding: 10px; border-radius: 4px; }
        .vis-container { border: 1px solid #ebeef5; padding: 10px; border-radius: 4px; min-height: 400px; height: calc(80vh - 100px); overflow-y: auto; }
    </style>
</head>
<body>
<div class="layui-fluid">
    <div class="layui-row">
        <div class="layui-col-md3">
            <div class="layui-card">
                <div class="layui-card-header">日期</div>
                <div class="layui-card-body">
                    <div id="date-tree"></div>
                </div>
            </div>
<div id="app" v-cloak>
    <div class="main-container">
        <!-- Sidebar: Date Tree -->
        <div class="sidebar">
            <el-card class="box-card" :body-style="{padding: '10px'}">
                <div slot="header">日期选择</div>
                <el-tree
                    ref="dateTree"
                    :data="dateTreeData"
                    :props="defaultProps"
                    node-key="id"
                    :default-expanded-keys="defaultExpandedKeys"
                    @node-click="handleNodeClick"
                    highlight-current
                    accordion>
                    <span class="custom-tree-node" slot-scope="{ node, data }">
                        <i v-if="data.children" class="el-icon-folder"></i>
                        <i v-else class="el-icon-document"></i>
                        <span style="margin-left: 5px;">{{ node.label }}</span>
                    </span>
                </el-tree>
            </el-card>
        </div>
        <div class="layui-col-md9">
            <div class="layui-card">
                <div class="layui-card-header">日志下载</div>
                <div class="layui-card-body">
                    <form class="layui-form toolbar" id="search-box">
                        <div class="layui-form-item">
                            <div class="layui-inline">
                                <label class="layui-form-label">选中日期:</label>
                                <div class="layui-input-inline">
                                    <input id="selected-day" class="layui-input" type="text" placeholder="yyyyMMdd" readonly>
                                </div>
                            </div>
                            <div class="layui-inline">
                                <label class="layui-form-label">设备类型:</label>
                                <div class="layui-input-inline">
                                    <select id="device-type-input" class="layui-input">
                                        <option value="">请选择</option>
                                        <option value="Crn">Crn</option>
                                        <option value="Devp">Devp</option>
                                        <option value="Rgv">Rgv</option>
                                    </select>
                                </div>
                            </div>
                            <div class="layui-inline">
                                <label class="layui-form-label">设备编号:</label>
                                <div class="layui-input-inline">
                                    <input id="device-no-input" class="layui-input" type="text" placeholder="请输入设备编号">
                                </div>
                            </div>
                            <div class="layui-inline">
                                <label class="layui-form-label">起始序号:</label>
                                <div class="layui-input-inline">
                                    <input id="file-offset" class="layui-input" type="text" placeholder="默认0">
                                </div>
                            </div>
                            <div class="layui-inline">
                                <label class="layui-form-label">最大文件数:</label>
                                <div class="layui-input-inline">
                                    <input id="file-limit" class="layui-input" type="text" placeholder="默认200">
                                </div>
                            </div>
                            <div class="layui-inline">
                                <button id="download-btn" type="button" class="layui-btn layui-btn-normal">下载</button>
                            </div>
                        </div>
                    </form>
                    <hr class="layui-bg-gray">
        <!-- Main Content -->
        <div class="content">
            <!-- Search Bar -->
            <div class="control-bar">
                <el-form :inline="true" :model="searchForm" size="small" style="margin-bottom: -18px;">
                    <el-form-item label="选中日期">
                        <el-input v-model="searchForm.day" placeholder="yyyyMMdd" readonly style="width: 120px;" disabled></el-input>
                    </el-form-item>
                    <el-form-item label="设备类型">
                        <el-select v-model="searchForm.type" placeholder="全部" clearable style="width: 100px;">
                            <el-option label="Crn" value="Crn"></el-option>
                            <el-option label="Devp" value="Devp"></el-option>
                            <el-option label="Rgv" value="Rgv"></el-option>
                        </el-select>
                    </el-form-item>
                    <el-form-item label="设备编号">
                        <el-input v-model="searchForm.deviceNo" placeholder="请输入编号" style="width: 120px;" clearable></el-input>
                    </el-form-item>
                    <el-form-item label="起始序号">
                        <el-input-number v-model="searchForm.offset" :min="0" controls-position="right" style="width: 100px;"></el-input-number>
                    </el-form-item>
                    <el-form-item label="最大文件">
                        <el-input-number v-model="searchForm.limit" :min="1" :max="1000" controls-position="right" style="width: 100px;"></el-input-number>
                    </el-form-item>
                    <el-form-item>
                        <el-button type="primary" icon="el-icon-download" @click="handleBatchDownload" :disabled="!canDownload">下载</el-button>
                    </el-form-item>
                </el-form>
            </div>
                    <div class="layui-row">
                        <div class="layui-col-xs12">
                            <div class="layui-card">
                                <div class="layui-card-header">该日设备列表</div>
                                <div class="layui-card-body">
                                    <div id="device-list" class="layui-row"></div>
            <!-- Device List -->
            <el-card class="box-card">
                <div slot="header" class="clearfix">
                    <span>设备列表</span>
                    <span style="float: right; color: #909399; font-size: 12px;">共 {{ filteredDeviceList.length }} 个设备</span>
                </div>
                <div v-if="loading" style="text-align: center; padding: 20px;">
                    <i class="el-icon-loading" style="font-size: 24px;"></i>
                </div>
                <div v-else-if="filteredDeviceList.length === 0" style="text-align: center; color: #909399; padding: 50px;">
                    <i class="el-icon-info" style="margin-right: 5px;"></i>暂无数据,请先选择日期
                </div>
                <div v-else>
                    <div v-for="(item, index) in filteredDeviceList" :key="index" class="device-item">
                        <div class="device-card">
                            <div class="device-info">
                                <div>
                                    <span class="info-text"><b>设备编号:</b> {{ item.deviceNo }}</span>
                                    <span class="info-text tag-group"><b>类型:</b> {{ item.types.join(', ') }}</span>
                                    <span class="info-text tag-group"><b>文件数:</b> {{ item.fileCount }}</span>
                                </div>
                                <div>
                                    <template v-for="t in item.types">
                                        <el-button size="mini" icon="el-icon-download" @click="downloadLog(item.deviceNo, t)">下载({{t}})</el-button>
                                        <el-button size="mini" type="success" icon="el-icon-view" @click="visualizeLog(item.deviceNo, t)">可视化({{t}})</el-button>
                                    </template>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            </el-card>
        </div>
    </div>
    <!-- Visualization Dialog -->
    <el-dialog
        :title="visualizationTitle"
        :visible.sync="visualizationVisible"
        width="90%"
        top="5vh"
        :close-on-click-modal="false"
        @close="handleVisualizationClose">
        <div class="vis-control-panel">
            <el-button-group>
                <el-button type="primary" icon="el-icon-video-play" @click="play" v-if="!isPlaying" size="small">播放</el-button>
                <el-button type="primary" icon="el-icon-video-pause" @click="pause" v-else size="small">暂停</el-button>
                <el-button type="warning" icon="el-icon-refresh-left" @click="reset" size="small">重置</el-button>
            </el-button-group>
            <div style="margin-left: 20px; flex: 1; padding-right: 20px;">
                <el-slider v-model="sliderValue" :max="maxSliderValue" @change="sliderChange" @input="sliderInput" :format-tooltip="formatTooltip"></el-slider>
            </div>
            <div style="width: 210px; font-size: 14px; font-weight: bold; font-family: monospace; display: flex; align-items: center;">
                {{ currentTimeStr }}
                <el-popover
                    placement="bottom"
                    width="200"
                    trigger="click"
                    v-model="jumpVisible"
                    @show="initJumpTime">
                    <div style="text-align: center;">
                        <el-time-picker
                            v-model="jumpTime"
                            size="small"
                            placeholder="选择时间"
                            style="width: 100%; margin-bottom: 10px;"
                            :picker-options="{ selectableRange: '00:00:00 - 23:59:59' }">
                        </el-time-picker>
                        <el-button type="primary" size="mini" @click="confirmJump" style="width: 100%;">跳转</el-button>
                    </div>
                    <el-button type="text" slot="reference" icon="el-icon-edit" style="margin-left: 5px; padding: 0;" title="跳转时间"></el-button>
                </el-popover>
            </div>
             <div style="margin-left: 10px;">
                <el-select v-model="playbackSpeed" style="width: 100px;" size="small" placeholder="倍速">
                    <el-option :value="1" label="1x"></el-option>
                    <el-option :value="5" label="5x"></el-option>
                    <el-option :value="10" label="10x"></el-option>
                    <el-option :value="50" label="50x"></el-option>
                    <el-option :value="100" label="100x"></el-option>
                    <el-option :value="200" label="200x"></el-option>
                    <el-option :value="500" label="500x"></el-option>
                    <el-option :value="1000" label="1000x"></el-option>
                </el-select>
            </div>
        </div>
        <div class="vis-container">
            <watch-crn-card v-if="visDeviceType === 'Crn'" ref="card" :auto-refresh="false" :read-only="true"></watch-crn-card>
            <watch-rgv-card v-else-if="visDeviceType === 'Rgv'" ref="card" :auto-refresh="false" :read-only="true"></watch-rgv-card>
            <watch-dual-crn-card v-else-if="visDeviceType === 'DualCrn'" ref="card" :auto-refresh="false" :read-only="true"></watch-dual-crn-card>
            <devp-card v-else-if="visDeviceType === 'Devp'" ref="card" :auto-refresh="false" :read-only="true"></devp-card>
            <div v-else style="text-align: center; padding: 50px; color: #909399;">
                未知设备类型: {{ visDeviceType }}
            </div>
        </div>
    </el-dialog>
    <!-- Download Progress Dialog -->
    <el-dialog title="文件下载中" :visible.sync="downloadDialogVisible" width="400px" :close-on-click-modal="false" :show-close="false">
        <div style="padding: 10px;">
            <div style="margin-bottom: 5px; font-size: 14px;">压缩生成进度</div>
            <el-progress :percentage="buildProgress" :text-inside="true" :stroke-width="18"></el-progress>
            <div style="margin: 20px 0 5px; font-size: 14px;">下载接收进度</div>
            <el-progress :percentage="receiveProgress" :text-inside="true" :stroke-width="18" status="success"></el-progress>
        </div>
    </el-dialog>
</div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/layui/layui.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/cool.js" charset="utf-8"></script>
<script src="../../static/vue/js/vue.min.js"></script>
<script src="../../static/vue/element/element.js"></script>
<script src="../../components/WatchCrnCard.js"></script>
<script src="../../components/WatchRgvCard.js"></script>
<script src="../../components/WatchDualCrnCard.js"></script>
<script src="../../components/DevpCard.js"></script>
<script type="text/javascript" src="../../static/js/deviceLogs/deviceLogs.js" charset="utf-8"></script>
</body>
</html>
</html>
src/main/webapp/views/index.html
@@ -208,7 +208,8 @@
    }
    let fakeRunning = false
    setInterval(function () {
    let fakeStatusInterval = null
    function checkFakeStatus() {
      $.ajax({
        url: baseUrl + "/openapi/getFakeSystemRunStatus",
        headers: {'token': localStorage.getItem('token')},
@@ -224,15 +225,23 @@
                $("#fakeShowText").text("仿真模拟未运行")
              }
              fakeRunning = running
              if (!fakeStatusInterval) {
                fakeStatusInterval = setInterval(checkFakeStatus, 1000);
              }
            }else {
              $("#fakeShow").hide()
              if (fakeStatusInterval) {
                clearInterval(fakeStatusInterval);
                fakeStatusInterval = null;
              }
            }
          }else {
            top.location.href = baseUrl + "/login";
          }
        }
      });
    }, 1000);
    }
    checkFakeStatus();
    $("#fakeShow").on("click", function () {
      if (fakeRunning) {