From ce13e25ed685ba5c961832d023ceafecf4f30d47 Mon Sep 17 00:00:00 2001
From: Junjie <DELL@qq.com>
Date: 星期六, 10 一月 2026 15:27:33 +0800
Subject: [PATCH] #

---
 src/main/webapp/components/WatchRgvCard.js                    |   33 
 src/main/java/com/zy/asrs/controller/DeviceLogController.java |  219 +++++++
 src/main/webapp/views/index.html                              |   13 
 src/main/webapp/components/DevpCard.js                        |   46 +
 src/main/webapp/components/WatchCrnCard.js                    |   33 
 src/main/webapp/static/js/deviceLogs/deviceLogs.js            | 1039 +++++++++++++++++++++++++++++-------
 src/main/webapp/components/WatchDualCrnCard.js                |   33 
 src/main/webapp/views/deviceLogs/deviceLogs.html              |  257 ++++++--
 8 files changed, 1,364 insertions(+), 309 deletions(-)

diff --git a/src/main/java/com/zy/asrs/controller/DeviceLogController.java b/src/main/java/com/zy/asrs/controller/DeviceLogController.java
index d5537e9..6640886 100644
--- a/src/main/java/com/zy/asrs/controller/DeviceLogController.java
+++ b/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) {
+                                // 蹇界暐瑙f瀽閿欒
+                            }
+                        }
+                    });
+                } 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);
+    }
 }
diff --git a/src/main/webapp/components/DevpCard.js b/src/main/webapp/components/DevpCard.js
index 9a4fa5c..24308c3 100644
--- a/src/main/webapp/components/DevpCard.js
+++ b/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 {
diff --git a/src/main/webapp/components/WatchCrnCard.js b/src/main/webapp/components/WatchCrnCard.js
index e088d98..d760b97 100644
--- a/src/main/webapp/components/WatchCrnCard.js
+++ b/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);
diff --git a/src/main/webapp/components/WatchDualCrnCard.js b/src/main/webapp/components/WatchDualCrnCard.js
index 4191450..d221857 100644
--- a/src/main/webapp/components/WatchDualCrnCard.js
+++ b/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);
diff --git a/src/main/webapp/components/WatchRgvCard.js b/src/main/webapp/components/WatchRgvCard.js
index 57e12c5..86cf8ee 100644
--- a/src/main/webapp/components/WatchRgvCard.js
+++ b/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; }
diff --git a/src/main/webapp/static/js/deviceLogs/deviceLogs.js b/src/main/webapp/static/js/deviceLogs/deviceLogs.js
index 31246f0..9d44d8f 100644
--- a/src/main/webapp/static/js/deviceLogs/deviceLogs.js
+++ b/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: '姝e湪璺宠浆鑷崇洰鏍囨椂闂� (鍔犺浇涓�)...', 
+                        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();
-});
-
+});
\ No newline at end of file
diff --git a/src/main/webapp/views/deviceLogs/deviceLogs.html b/src/main/webapp/views/deviceLogs/deviceLogs.html
index c09cc42..56f8d78 100644
--- a/src/main/webapp/views/deviceLogs/deviceLogs.html
+++ b/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>
\ No newline at end of file
diff --git a/src/main/webapp/views/index.html b/src/main/webapp/views/index.html
index 90597c8..6c52b6f 100644
--- a/src/main/webapp/views/index.html
+++ b/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) {

--
Gitblit v1.9.1