#
Junjie
9 小时以前 fd82105a3dfe347c4c9acb0410c117d8d67c9339
#
7个文件已修改
3786 ■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/DeviceLogController.java 436 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/DevpCard.js 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchCrnCard.js 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchDualCrnCard.js 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/WatchRgvCard.js 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/deviceLogs/deviceLogs.js 1900 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/deviceLogs/deviceLogs.html 1426 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/DeviceLogController.java
@@ -14,6 +14,8 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -27,6 +29,16 @@
@RestController
public class DeviceLogController extends BaseController {
    private static final List<String> DEVICE_TYPE_ORDER = Arrays.asList("Crn", "DualCrn", "Rgv", "Devp");
    private static final Map<String, String> DEVICE_TYPE_LABELS = new LinkedHashMap<>();
    static {
        DEVICE_TYPE_LABELS.put("Crn", "堆垛机");
        DEVICE_TYPE_LABELS.put("DualCrn", "双工位堆垛机");
        DEVICE_TYPE_LABELS.put("Rgv", "RGV");
        DEVICE_TYPE_LABELS.put("Devp", "输送设备");
    }
    @Value("${deviceLogStorage.loggingPath}")
    private String loggingPath;
@@ -36,6 +48,31 @@
        int totalCount;
        int processedCount;
        boolean finished;
    }
    private static class FileNameInfo {
        String type;
        String deviceNo;
        String day;
        int index;
    }
    private static class FileTimeRange {
        Long startTime;
        Long endTime;
    }
    private static class DeviceAggregate {
        String type;
        String typeLabel;
        String deviceNo;
        int fileCount;
        Long firstTime;
        Long lastTime;
        Integer firstIndex;
        Integer lastIndex;
        Path firstFile;
        Path lastFile;
    }
    private static final Map<String, ProgressInfo> DOWNLOAD_PROGRESS = new ConcurrentHashMap<>();
@@ -135,6 +172,142 @@
            return R.ok(res);
        } catch (Exception e) {
            return R.error("读取设备列表失败");
        }
    }
    @RequestMapping(value = "/deviceLog/day/{day}/summary/auth")
    @ManagerAuth
    public R summary(@PathVariable("day") String day) {
        try {
            String dayClean = normalizeDay(day);
            if (dayClean == null) {
                return R.error("日期格式错误");
            }
            Path dayDir = Paths.get(loggingPath, dayClean);
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.ok(buildEmptySummary());
            }
            Map<String, DeviceAggregate> aggregateMap = new LinkedHashMap<>();
            try (Stream<Path> stream = Files.list(dayDir)) {
                List<Path> files = stream
                        .filter(p -> !Files.isDirectory(p) && p.getFileName().toString().endsWith(".log"))
                        .collect(Collectors.toList());
                for (Path file : files) {
                    FileNameInfo info = parseFileName(file.getFileName().toString());
                    if (info == null || !dayClean.equals(info.day) || !DEVICE_TYPE_LABELS.containsKey(info.type)) {
                        continue;
                    }
                    String key = info.type + ":" + info.deviceNo;
                    DeviceAggregate aggregate = aggregateMap.computeIfAbsent(key, k -> {
                        DeviceAggregate x = new DeviceAggregate();
                        x.type = info.type;
                        x.typeLabel = DEVICE_TYPE_LABELS.get(info.type);
                        x.deviceNo = info.deviceNo;
                        return x;
                    });
                    aggregate.fileCount += 1;
                    if (aggregate.firstIndex == null || info.index < aggregate.firstIndex) {
                        aggregate.firstIndex = info.index;
                        aggregate.firstFile = file;
                    }
                    if (aggregate.lastIndex == null || info.index > aggregate.lastIndex) {
                        aggregate.lastIndex = info.index;
                        aggregate.lastFile = file;
                    }
                }
            }
            for (DeviceAggregate aggregate : aggregateMap.values()) {
                if (aggregate.firstFile != null) {
                    FileTimeRange firstRange = readFileTimeRange(aggregate.firstFile);
                    aggregate.firstTime = firstRange.startTime != null ? firstRange.startTime : firstRange.endTime;
                }
                if (aggregate.lastFile != null) {
                    FileTimeRange lastRange = readFileTimeRange(aggregate.lastFile);
                    aggregate.lastTime = lastRange.endTime != null ? lastRange.endTime : lastRange.startTime;
                }
            }
            return R.ok(buildSummaryResponse(aggregateMap.values()));
        } catch (Exception e) {
            log.error("读取设备日志摘要失败", e);
            return R.error("读取设备日志摘要失败");
        }
    }
    @RequestMapping(value = "/deviceLog/day/{day}/timeline/auth")
    @ManagerAuth
    public R timeline(@PathVariable("day") String day,
                      @RequestParam("type") String type,
                      @RequestParam("deviceNo") String deviceNo) {
        try {
            String dayClean = normalizeDay(day);
            if (dayClean == null) {
                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("未找到日志文件");
            }
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo);
            if (files.isEmpty()) {
                return R.error("未找到日志文件");
            }
            List<Map<String, Object>> segments = new ArrayList<>();
            Long startTime = null;
            for (int i = 0; i < files.size(); i++) {
                Long segmentStart = getFileStartTime(files.get(i));
                if (segmentStart != null && (startTime == null || segmentStart < startTime)) {
                    startTime = segmentStart;
                }
                Map<String, Object> segment = new LinkedHashMap<>();
                segment.put("offset", i);
                segment.put("startTime", segmentStart);
                segment.put("endTime", null);
                segments.add(segment);
            }
            Long endTime = getFileEndTime(files.get(files.size() - 1));
            if (endTime == null) {
                for (int i = segments.size() - 1; i >= 0; i--) {
                    Long segmentStart = (Long) segments.get(i).get("startTime");
                    if (segmentStart != null) {
                        endTime = segmentStart;
                        break;
                    }
                }
            }
            for (int i = 0; i < segments.size(); i++) {
                Long segmentEnd = null;
                if (i < segments.size() - 1) {
                    segmentEnd = (Long) segments.get(i + 1).get("startTime");
                }
                if (segmentEnd == null && i == segments.size() - 1) {
                    segmentEnd = endTime;
                }
                if (segmentEnd == null) {
                    segmentEnd = (Long) segments.get(i).get("startTime");
                }
                segments.get(i).put("endTime", segmentEnd);
            }
            Map<String, Object> result = new HashMap<>();
            result.put("type", type);
            result.put("typeLabel", DEVICE_TYPE_LABELS.getOrDefault(type, type));
            result.put("deviceNo", deviceNo);
            result.put("startTime", startTime);
            result.put("endTime", endTime);
            result.put("totalFiles", files.size());
            result.put("segments", segments);
            return R.ok(result);
        } catch (Exception e) {
            log.error("读取设备日志时间轴失败", e);
            return R.error("读取设备日志时间轴失败");
        }
    }
@@ -306,13 +479,21 @@
    
    private Long getFileStartTime(Path file) {
        try {
            String firstLine = null;
            try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
                firstLine = lines.findFirst().orElse(null);
            }
            String firstLine = readFirstNonBlankLine(file);
            if (firstLine == null) return null;
            DeviceDataLog firstLog = JSON.parseObject(firstLine, DeviceDataLog.class);
            return firstLog.getCreateTime().getTime();
        } catch (Exception e) {
            return null;
        }
    }
    private Long getFileEndTime(Path file) {
        try {
            String lastLine = readLastNonBlankLine(file);
            if (lastLine == null) return null;
            DeviceDataLog lastLog = JSON.parseObject(lastLine, DeviceDataLog.class);
            return lastLog.getCreateTime().getTime();
        } catch (Exception e) {
            return null;
        }
@@ -346,31 +527,8 @@
                response.setStatus(404);
                return;
            }
            List<Path> files = Files.list(dayDir)
                    .filter(p -> {
                        String name = p.getFileName().toString();
                        String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
                        return name.endsWith(".log") && name.startsWith(prefix);
                    }).collect(Collectors.toList());
            // 排序(按文件中的索引号递增)
            String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
            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 ? 200 : limit;
            int to = Math.min(files.size(), from + max);
            if (from >= files.size()) {
                response.setStatus(404);
                return;
            }
            files = files.subList(from, to);
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo);
            files = sliceDownloadFiles(files, offset, limit);
            if (files.isEmpty()) {
                response.setStatus(404);
                return;
@@ -447,29 +605,14 @@
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.error("当日目录不存在");
            }
            List<Path> files = Files.list(dayDir)
                    .filter(p -> {
                        String name = p.getFileName().toString();
                        String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
                        return name.endsWith(".log") && name.startsWith(prefix);
                    }).collect(Collectors.toList());
            String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
            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 ? 200 : limit;
            int to = Math.min(files.size(), from + max);
            if (from >= files.size()) {
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo);
            if ((offset != null && offset >= files.size())) {
                return R.error("起始序号超出范围");
            }
            files = files.subList(from, to);
            files = sliceDownloadFiles(files, offset, limit);
            if (files.isEmpty()) {
                return R.error("未找到日志文件");
            }
            String id = UUID.randomUUID().toString();
            ProgressInfo info = new ProgressInfo();
            info.totalCount = files.size();
@@ -552,4 +695,197 @@
        
        return R.ok(enums);
    }
    private Map<String, Object> buildEmptySummary() {
        return buildSummaryResponse(Collections.emptyList());
    }
    private Map<String, Object> buildSummaryResponse(Collection<DeviceAggregate> aggregates) {
        List<DeviceAggregate> aggregateList = new ArrayList<>(aggregates);
        Map<String, Object> stats = new LinkedHashMap<>();
        Map<String, Object> typeCounts = new LinkedHashMap<>();
        int totalFiles = 0;
        for (String type : DEVICE_TYPE_ORDER) {
            int count = (int) aggregateList.stream().filter(item -> type.equals(item.type)).count();
            typeCounts.put(type, count);
        }
        for (DeviceAggregate aggregate : aggregateList) {
            totalFiles += aggregate.fileCount;
        }
        stats.put("totalDevices", aggregateList.size());
        stats.put("totalFiles", totalFiles);
        stats.put("typeCounts", typeCounts);
        List<Map<String, Object>> groups = new ArrayList<>();
        for (String type : DEVICE_TYPE_ORDER) {
            List<DeviceAggregate> devices = aggregateList.stream()
                    .filter(item -> type.equals(item.type))
                    .sorted(Comparator.comparingInt(item -> parseDeviceNo(item.deviceNo)))
                    .collect(Collectors.toList());
            Map<String, Object> group = new LinkedHashMap<>();
            group.put("type", type);
            group.put("typeLabel", DEVICE_TYPE_LABELS.get(type));
            group.put("deviceCount", devices.size());
            group.put("totalFiles", devices.stream().mapToInt(item -> item.fileCount).sum());
            group.put("devices", devices.stream().map(item -> {
                Map<String, Object> x = new LinkedHashMap<>();
                x.put("type", item.type);
                x.put("typeLabel", item.typeLabel);
                x.put("deviceNo", item.deviceNo);
                x.put("fileCount", item.fileCount);
                x.put("firstTime", item.firstTime);
                x.put("lastTime", item.lastTime);
                return x;
            }).collect(Collectors.toList()));
            groups.add(group);
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("stats", stats);
        result.put("groups", groups);
        return result;
    }
    private String normalizeDay(String day) {
        String dayClean = day == null ? null : day.replaceAll("\\D", "");
        if (dayClean == null || dayClean.length() != 8 || !dayClean.chars().allMatch(Character::isDigit)) {
            return null;
        }
        return dayClean;
    }
    private List<Path> findDeviceFiles(Path dayDir, String dayClean, String type, String deviceNo) throws Exception {
        String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
        List<Path> files;
        try (Stream<Path> stream = Files.list(dayDir)) {
            files = stream
                    .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;
            }
        }));
        return files;
    }
    private List<Path> sliceDownloadFiles(List<Path> files, Integer offset, Integer limit) {
        if (files == null || files.isEmpty()) {
            return Collections.emptyList();
        }
        int from = offset == null || offset < 0 ? 0 : offset;
        if (from >= files.size()) {
            return Collections.emptyList();
        }
        if (offset == null && limit == null) {
            return new ArrayList<>(files);
        }
        int to;
        if (limit == null || limit <= 0) {
            to = files.size();
        } else {
            to = Math.min(files.size(), from + limit);
        }
        return new ArrayList<>(files.subList(from, to));
    }
    private FileNameInfo parseFileName(String fileName) {
        if (fileName == null || !fileName.endsWith(".log")) {
            return null;
        }
        String[] parts = fileName.split("_", 4);
        if (parts.length < 4) {
            return null;
        }
        FileNameInfo info = new FileNameInfo();
        info.type = parts[0];
        info.deviceNo = parts[1];
        info.day = parts[2];
        try {
            info.index = Integer.parseInt(parts[3].replace(".log", ""));
        } catch (Exception e) {
            return null;
        }
        return info;
    }
    private int parseDeviceNo(String deviceNo) {
        try {
            return Integer.parseInt(String.valueOf(deviceNo));
        } catch (Exception e) {
            return Integer.MAX_VALUE;
        }
    }
    private FileTimeRange readFileTimeRange(Path file) {
        FileTimeRange range = new FileTimeRange();
        try {
            String firstLine = readFirstNonBlankLine(file);
            String lastLine = readLastNonBlankLine(file);
            range.startTime = parseLogTime(firstLine);
            range.endTime = parseLogTime(lastLine);
            return range;
        } catch (Exception e) {
            return range;
        }
    }
    private Long parseLogTime(String line) {
        try {
            if (line == null || line.trim().isEmpty()) {
                return null;
            }
            DeviceDataLog logItem = JSON.parseObject(line, DeviceDataLog.class);
            return logItem != null && logItem.getCreateTime() != null ? logItem.getCreateTime().getTime() : null;
        } catch (Exception e) {
            return null;
        }
    }
    private String readFirstNonBlankLine(Path file) {
        try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
            return lines.filter(line -> line != null && !line.trim().isEmpty()).findFirst().orElse(null);
        } catch (Exception e) {
            return null;
        }
    }
    private String readLastNonBlankLine(Path file) {
        try (RandomAccessFile randomAccessFile = new RandomAccessFile(file.toFile(), "r")) {
            long length = randomAccessFile.length();
            if (length <= 0) {
                return null;
            }
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            for (long pointer = length - 1; pointer >= 0; pointer--) {
                randomAccessFile.seek(pointer);
                int read = randomAccessFile.read();
                if (read == '\n' || read == '\r') {
                    if (baos.size() > 0) {
                        break;
                    }
                    continue;
                }
                baos.write(read);
            }
            byte[] bytes = baos.toByteArray();
            for (int i = 0, j = bytes.length - 1; i < j; i++, j--) {
                byte tmp = bytes[i];
                bytes[i] = bytes[j];
                bytes[j] = tmp;
            }
            String line = new String(bytes, StandardCharsets.UTF_8).trim();
            return line.isEmpty() ? null : line;
        } catch (Exception e) {
            return null;
        }
    }
}
src/main/webapp/components/DevpCard.js
@@ -3,7 +3,7 @@
    <div class="mc-root">
      <div class="mc-toolbar">
        <div class="mc-title">输送监控</div>
        <div class="mc-search">
        <div v-if="!readOnly" class="mc-search">
          <input class="mc-input" v-model="searchStationId" placeholder="请输入站号" />
          <button type="button" class="mc-btn mc-btn-ghost" @click="getDevpStateInfo">查询</button>
        </div>
@@ -88,7 +88,7 @@
        <div v-if="displayStationList.length === 0" class="mc-empty">当前没有可展示的站点数据</div>
      </div>
      <div class="mc-footer">
      <div v-if="!readOnly || totalPages > 1" class="mc-footer">
        <button type="button" class="mc-page-btn" :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)">上一页</button>
        <span>{{ currentPage }} / {{ totalPages }}</span>
        <button type="button" class="mc-page-btn" :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)">下一页</button>
@@ -113,7 +113,7 @@
        targetStationId: ""
      },
      barcodePreviewCache: {},
      pageSize: 12,
      pageSize: this.readOnly ? 24 : 12,
      currentPage: 1,
      timer: null
    };
src/main/webapp/components/WatchCrnCard.js
@@ -3,7 +3,7 @@
    <div class="mc-root">
      <div class="mc-toolbar">
        <div class="mc-title">堆垛机监控</div>
        <div class="mc-search">
        <div v-if="!readOnly" class="mc-search">
          <input class="mc-input" v-model="searchCrnNo" placeholder="请输入堆垛机号" />
          <button type="button" class="mc-btn mc-btn-ghost" @click="getCrnStateInfo">查询</button>
        </div>
@@ -67,7 +67,7 @@
        <div v-if="displayCrnList.length === 0" class="mc-empty">当前没有可展示的堆垛机数据</div>
      </div>
      <div class="mc-footer">
      <div v-if="!readOnly || totalPages > 1" class="mc-footer">
        <button type="button" class="mc-page-btn" :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)">上一页</button>
        <span>{{ currentPage }} / {{ totalPages }}</span>
        <button type="button" class="mc-page-btn" :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)">下一页</button>
@@ -91,7 +91,7 @@
        sourceLocNo: "",
        targetLocNo: ""
      },
      pageSize: 12,
      pageSize: this.readOnly ? 24 : 12,
      currentPage: 1,
      timer: null
    };
src/main/webapp/components/WatchDualCrnCard.js
@@ -3,7 +3,7 @@
    <div class="mc-root">
      <div class="mc-toolbar">
        <div class="mc-title">双工位堆垛机监控</div>
        <div class="mc-search">
        <div v-if="!readOnly" class="mc-search">
          <input class="mc-input" v-model="searchCrnNo" placeholder="请输入堆垛机号" />
          <button type="button" class="mc-btn mc-btn-ghost" @click="getDualCrnStateInfo">查询</button>
        </div>
@@ -80,7 +80,7 @@
        <div v-if="displayCrnList.length === 0" class="mc-empty">当前没有可展示的双工位堆垛机数据</div>
      </div>
      <div class="mc-footer">
      <div v-if="!readOnly || totalPages > 1" class="mc-footer">
        <button type="button" class="mc-page-btn" :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)">上一页</button>
        <span>{{ currentPage }} / {{ totalPages }}</span>
        <button type="button" class="mc-page-btn" :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)">下一页</button>
@@ -105,7 +105,7 @@
        targetLocNo: "",
        station: 1
      },
      pageSize: 12,
      pageSize: this.readOnly ? 24 : 12,
      currentPage: 1,
      timer: null
    };
src/main/webapp/components/WatchRgvCard.js
@@ -3,7 +3,7 @@
    <div class="mc-root">
      <div class="mc-toolbar">
        <div class="mc-title">RGV监控</div>
        <div class="mc-search">
        <div v-if="!readOnly" class="mc-search">
          <input class="mc-input" v-model="searchRgvNo" placeholder="请输入RGV号" />
          <button type="button" class="mc-btn mc-btn-ghost" @click="getRgvStateInfo">查询</button>
        </div>
@@ -67,7 +67,7 @@
        <div v-if="displayRgvList.length === 0" class="mc-empty">当前没有可展示的RGV数据</div>
      </div>
      <div class="mc-footer">
      <div v-if="!readOnly || totalPages > 1" class="mc-footer">
        <button type="button" class="mc-page-btn" :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)">上一页</button>
        <span>{{ currentPage }} / {{ totalPages }}</span>
        <button type="button" class="mc-page-btn" :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)">下一页</button>
@@ -91,7 +91,7 @@
        sourcePos: "",
        targetPos: ""
      },
      pageSize: 12,
      pageSize: this.readOnly ? 24 : 12,
      currentPage: 1,
      timer: null
    };
src/main/webapp/static/js/deviceLogs/deviceLogs.js
@@ -1,99 +1,313 @@
var app = new Vue({
    el: '#app',
    data: {
        // Sidebar Data
        dateTreeData: [],
        defaultProps: {
            children: 'children',
            label: 'title'
        },
        defaultExpandedKeys: [],
        // Search & List Data
        searchForm: {
            day: '',
            type: '',
            deviceNo: '',
            offset: 0,
            limit: 200
        typeOrder: ['Crn', 'DualCrn', 'Rgv', 'Devp'],
        typeLabels: {
            Crn: '堆垛机',
            DualCrn: '双工位堆垛机',
            Rgv: 'RGV',
            Devp: '输送设备'
        },
        deviceList: [],
        loading: false,
        // Enums
        selectedDay: '',
        searchDeviceNo: '',
        activeType: '',
        viewMode: 'picker',
        deviceSummary: {
            stats: {
                totalDevices: 0,
                totalFiles: 0,
                typeCounts: {}
            },
            groups: []
        },
        summaryLoading: false,
        deviceEnums: {},
        // Visualization State
        visualizationVisible: false,
        visDeviceType: '',
        visDeviceNo: '',
        logs: [],
        isPlaying: false,
        playbackSpeed: 1,
        sliderValue: 0,
        selectedType: '',
        selectedDeviceNo: '',
        activeDeviceKey: '',
        timelineMeta: {
            type: '',
            typeLabel: '',
            deviceNo: '',
        startTime: 0,
        endTime: 0,
            totalFiles: 0,
            segments: []
        },
        timelineLoading: false,
        logLoading: false,
        logLoadError: '',
        logRows: [],
        loadedOffsets: {},
        loadingOffsets: {},
        selectedTimestamp: 0,
        loadedSegmentRadius: 1,
        playbackTickMs: 120,
        logWindowAnchorOffset: -1,
        isPlaying: false,
        playbackSpeed: 1,
        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,
        detailTab: 'logs',
        rawTab: 'wcs',
        // 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;
        summaryStats: function () {
            return this.deviceSummary && this.deviceSummary.stats ? this.deviceSummary.stats : {
                totalDevices: 0,
                totalFiles: 0,
                typeCounts: {}
            };
        },
        visualizationTitle() {
            return this.i18n('deviceLogs.visualizationPrefix', '日志可视化 - ') + this.visDeviceType + ' ' + this.visDeviceNo + ' (' + this.searchForm.day + ')';
        deviceGroups: function () {
            var self = this;
            var groups = {};
            (this.deviceSummary.groups || []).forEach(function (group) {
                groups[group.type] = group;
            });
            return this.typeOrder.map(function (type) {
                var group = groups[type] || {
                    type: type,
                    typeLabel: self.typeLabels[type] || type,
                    deviceCount: 0,
                    totalFiles: 0,
                    devices: []
                };
                group.devices = (group.devices || []).slice().sort(function (a, b) {
                    return self.parseDeviceNo(a.deviceNo) - self.parseDeviceNo(b.deviceNo);
                });
                return group;
            });
        },
        downloadDialogTitle() {
        recentDays: function () {
            var days = this.flattenDayNodes(this.dateTreeData);
            days.sort(function (a, b) {
                return b.day.localeCompare(a.day);
            });
            return days.slice(0, 7);
        },
        activeGroup: function () {
            var match = null;
            (this.deviceGroups || []).forEach(function (group) {
                if (group.type === this.activeType) {
                    match = group;
                }
            }, this);
            if (match) {
                return match;
            }
            for (var i = 0; i < this.deviceGroups.length; i++) {
                if ((this.deviceGroups[i].devices || []).length > 0) {
                    return this.deviceGroups[i];
                }
            }
            return this.deviceGroups[0] || null;
        },
        filteredDevices: function () {
            var group = this.activeGroup;
            var devices = group && group.devices ? group.devices.slice() : [];
            var keyword = String(this.searchDeviceNo || '').trim();
            if (!keyword) {
                return devices;
            }
            return devices.filter(function (item) {
                return String(item.deviceNo).indexOf(keyword) >= 0;
            });
        },
        selectedDeviceSummary: function () {
            var key = this.activeDeviceKey;
            var found = null;
            (this.deviceGroups || []).forEach(function (group) {
                (group.devices || []).forEach(function (device) {
                    if (this.buildDeviceKey(device.type, device.deviceNo) === key) {
                        found = device;
                    }
                }, this);
            }, this);
            return found;
        },
        sliderMax: function () {
            if (!this.timelineMeta.startTime || !this.timelineMeta.endTime) {
                return 0;
            }
            return Math.max(0, this.timelineMeta.endTime - this.timelineMeta.startTime);
        },
        sliderValue: function () {
            if (!this.timelineMeta.startTime || !this.selectedTimestamp) {
                return 0;
            }
            return Math.max(0, this.selectedTimestamp - this.timelineMeta.startTime);
        },
        currentTimeStr: function () {
            if (!this.selectedTimestamp) {
                return '--';
            }
            return this.formatTimestamp(this.selectedTimestamp, true);
        },
        selectedLogRow: function () {
            if (!this.logRows.length) {
                return null;
            }
            var idx = this.binarySearch(this.selectedTimestamp);
            if (idx < 0) {
                return this.logRows[0];
            }
            return this.logRows[idx];
        },
        selectedLogKey: function () {
            return this.selectedLogRow ? this.selectedLogRow._key : '';
        },
        currentStatusLabel: function () {
            if (!this.selectedLogRow) {
                return '未定位';
            }
            return this.selectedLogRow._summary.statusLabel;
        },
        currentStatusTone: function () {
            if (!this.selectedLogRow) {
                return 'muted';
            }
            return this.selectedLogRow._summary.tone;
        },
        currentLogTitle: function () {
            if (!this.selectedLogRow) {
                return '等待选择日志';
            }
            return this.selectedLogRow._summary.title;
        },
        currentLogDetail: function () {
            if (!this.selectedLogRow) {
                return this.selectedDeviceSummary ? '点击一条日志或拖动时间轴后,同步查看该时刻的状态。' : '请先选择设备。';
            }
            return this.selectedLogRow._summary.detail + (this.selectedLogRow._summary.hint ? (' · ' + this.selectedLogRow._summary.hint) : '');
        },
        timelineRangeText: function () {
            var start = this.timelineMeta.startTime || (this.selectedDeviceSummary && this.selectedDeviceSummary.firstTime) || 0;
            var end = this.timelineMeta.endTime || (this.selectedDeviceSummary && this.selectedDeviceSummary.lastTime) || 0;
            if (!start && !end) {
                return '--';
            }
            return this.formatTimestamp(start, false) + ' ~ ' + this.formatTimestamp(end, false);
        },
        loadedSegmentCount: function () {
            return this.getLoadedOffsetNumbers().length;
        },
        visualComponentName: function () {
            if (this.selectedType === 'Crn') {
                return 'watch-crn-card';
            }
            if (this.selectedType === 'DualCrn') {
                return 'watch-dual-crn-card';
            }
            if (this.selectedType === 'Rgv') {
                return 'watch-rgv-card';
            }
            if (this.selectedType === 'Devp') {
                return 'devp-card';
            }
            return '';
        },
        visualItems: function () {
            return this.selectedLogRow ? this.selectedLogRow._visualItems : [];
        },
        visualParam: function () {
            return this.selectedLogRow ? this.selectedLogRow._visualParam : {};
        },
        activeRawText: function () {
            if (!this.selectedLogRow) {
                return '';
            }
            return this.rawTab === 'origin'
                ? this.getOriginDataText(this.selectedLogRow)
                : this.getWcsDataText(this.selectedLogRow);
        },
        activeRawHint: function () {
            if (!this.selectedLogRow) {
                return '';
            }
            if (this.rawTab === 'origin') {
                if (!this.selectedLogRow.originData) {
                    return '当前记录没有 originData。';
                }
                return this.safeParse(this.selectedLogRow.originData)
                    ? 'originData 已按 JSON 格式化展示。'
                    : 'originData 不是合法 JSON,以下展示原始文本。';
            }
            if (!this.selectedLogRow.wcsData) {
                return '当前记录没有 wcsData。';
            }
            return this.selectedLogRow._protocol
                ? 'wcsData 已按 JSON 格式化展示。'
                : 'wcsData 不是合法 JSON,以下展示原始文本。';
        },
        canDownload: function () {
            return !!(this.selectedDay && this.selectedType && this.selectedDeviceNo);
        },
        canPlay: function () {
            return !!(this.selectedDeviceSummary && this.timelineMeta.startTime && this.timelineMeta.endTime && this.sliderMax > 0);
        },
        canLoadPreviousSegment: function () {
            if (!this.selectedDeviceSummary || !(this.timelineMeta.totalFiles > 0)) {
                return false;
            }
            var offsets = this.getLoadedOffsetNumbers();
            if (!offsets.length) {
                return true;
            }
            return offsets[0] > 0;
        },
        canLoadNextSegment: function () {
            if (!this.selectedDeviceSummary || !(this.timelineMeta.totalFiles > 0)) {
                return false;
            }
            var offsets = this.getLoadedOffsetNumbers();
            if (!offsets.length) {
                return true;
            }
            return offsets[offsets.length - 1] < this.timelineMeta.totalFiles - 1;
        },
        downloadDialogTitle: function () {
            return this.i18n('deviceLogs.downloadDialogTitle', '文件下载中');
        },
        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() {
    created: function () {
        this.loadDeviceEnums();
        this.loadDateTree();
    },
    mounted() {
    mounted: function () {
        if (window.WCS_I18N && typeof window.WCS_I18N.onReady === 'function') {
            let that = this;
            var self = this;
            window.WCS_I18N.onReady(function () {
                that.$forceUpdate();
                self.$forceUpdate();
            });
        }
    },
    beforeDestroy: function () {
        this.pause();
        if (this.downloadTimer) {
            clearInterval(this.downloadTimer);
            this.downloadTimer = null;
        }
    },
    methods: {
        i18n(key, fallback, params) {
        i18n: function (key, fallback, params) {
            if (window.WCS_I18N && typeof window.WCS_I18N.t === 'function') {
                var translated = window.WCS_I18N.t(key, params);
                if (translated && translated !== key) {
@@ -102,581 +316,555 @@
            }
            return fallback || key;
        },
        // --- Initialization ---
        loadDeviceEnums() {
            let that = this;
        emptySummary: function () {
            var self = this;
            return {
                stats: {
                    totalDevices: 0,
                    totalFiles: 0,
                    typeCounts: {}
                },
                groups: this.typeOrder.map(function (type) {
                    return {
                        type: type,
                        typeLabel: self.typeLabels[type] || type,
                        deviceCount: 0,
                        totalFiles: 0,
                        devices: []
                    };
                })
            };
        },
        createEmptyTimeline: function () {
            return {
                type: '',
                typeLabel: '',
                deviceNo: '',
                startTime: 0,
                endTime: 0,
                totalFiles: 0,
                segments: []
            };
        },
        loadDeviceEnums: function () {
            var self = this;
            $.ajax({
                url: baseUrl + "/deviceLog/enums/auth",
                headers: {'token': localStorage.getItem('token')},
                url: baseUrl + '/deviceLog/enums/auth',
                headers: { token: localStorage.getItem('token') },
                method: 'GET',
                success: function (res) {
                    if (res.code === 200) {
                        that.deviceEnums = res.data || {};
                    if (res && res.code === 200) {
                        self.deviceEnums = res.data || {};
                    }
                }
            });
        },
        // --- Date Tree ---
        loadDateTree() {
            let that = this;
        loadDateTree: function () {
            var self = this;
            $.ajax({
                url: baseUrl + "/deviceLog/dates/auth",
                headers: {'token': localStorage.getItem('token')},
                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];
                    if (res && res.code === 200) {
                        self.dateTreeData = res.data || [];
                        var latest = self.recentDays.length ? self.recentDays[0].day : '';
                        self.defaultExpandedKeys = self.resolveExpandedKeys(latest);
                        if (latest) {
                            self.selectDay(latest);
                        }
                    } else if (res.code === 403) {
                        top.location.href = baseUrl + "/";
                    } else if (res && res.code === 403) {
                        top.location.href = baseUrl + '/';
                    } else {
                        that.$message.error(res.msg || '加载日期失败');
                        self.$message.error((res && res.msg) || '加载日期失败');
                    }
                },
                error: function () {
                    self.$message.error('加载日期失败');
                }
            });
        },
        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 });
                    });
                });
            });
        resolveExpandedKeys: function (day) {
            if (!day || day.length !== 8) {
                return [];
            }
            return [day.substring(0, 4), day.substring(0, 4) + '-' + day.substring(4, 6)];
        },
        flattenDayNodes: function (nodes) {
            var result = [];
            Object.keys(monthMap).sort().reverse().forEach(function (month) {
                result.push({ title: month + '月', id: month, children: monthMap[month] });
            (nodes || []).forEach(function (yearNode) {
                (yearNode.children || []).forEach(function (monthNode) {
                    (monthNode.children || []).forEach(function (dayNode) {
                        if (dayNode.day) {
                            result.push({
                                day: dayNode.day,
                                label: dayNode.day.substring(4, 6) + '-' + dayNode.day.substring(6, 8)
                            });
                        }
                    });
                });
            });
            return result;
        },
        handleNodeClick(data) {
            if (data.day && data.day.length === 8) {
                this.searchForm.day = data.day;
                this.loadDevices(data.day);
        handleNodeClick: function (data) {
            if (data && data.day) {
                this.selectDay(data.day);
            }
        },
        handleRecentDayClick: function (day) {
            this.selectDay(day);
        },
        selectDay: function (day) {
            if (!day) {
                return;
            }
            this.selectedDay = day;
            this.searchDeviceNo = '';
            this.pause();
            this.summaryLoading = true;
            this.resetSelectionState();
        // --- Device List ---
        loadDevices(day) {
            this.loading = true;
            this.deviceList = [];
            let that = this;
            var self = this;
            $.ajax({
                url: baseUrl + "/deviceLog/day/" + day + "/devices/auth",
                headers: {'token': localStorage.getItem('token')},
                url: baseUrl + '/deviceLog/day/' + day + '/summary/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 + "/";
                    self.summaryLoading = false;
                    if (res && res.code === 200) {
                        self.deviceSummary = self.normalizeSummaryData(res.data);
                        self.activeType = self.pickActiveType();
                        self.resetSelectionState();
                    } else if (res && res.code === 403) {
                        top.location.href = baseUrl + '/';
                    } else {
                        that.$message.error(res.msg || '加载设备失败');
                        self.deviceSummary = self.emptySummary();
                        self.$message.error((res && res.msg) || '加载设备摘要失败');
                    }
                },
                error: function() {
                    that.loading = false;
                    that.$message.error('请求失败');
                    self.summaryLoading = false;
                    self.deviceSummary = self.emptySummary();
                    self.$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 pid = res.data.progressId;
                    that.startDownloadProgress(pid);
                    that.performDownloadRequest(day, type, deviceNo, offset, limit, pid);
                }
            });
        },
        startDownloadProgress(pid) {
            this.downloadDialogVisible = true;
            this.buildProgress = 0;
            this.receiveProgress = 0;
            let that = this;
            this.downloadTimer = 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;
                            that.buildProgress = percent;
                        }
                    }
                });
            }, 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;
                        }
        normalizeSummaryData: function (data) {
            var self = this;
            var summary = this.emptySummary();
            if (data && data.stats) {
                summary.stats = {
                    totalDevices: data.stats.totalDevices || 0,
                    totalFiles: data.stats.totalFiles || 0,
                    typeCounts: data.stats.typeCounts || {}
                    };
                    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);
            var groupMap = {};
            ((data && data.groups) || []).forEach(function (group) {
                groupMap[group.type] = {
                    type: group.type,
                    typeLabel: group.typeLabel || self.typeLabels[group.type] || group.type,
                    deviceCount: group.deviceCount || 0,
                    totalFiles: group.totalFiles || 0,
                    devices: (group.devices || []).map(function (device) {
                        return {
                            type: device.type,
                            typeLabel: device.typeLabel || self.typeLabels[device.type] || device.type,
                            deviceNo: String(device.deviceNo),
                            fileCount: device.fileCount || 0,
                            firstTime: device.firstTime || 0,
                            lastTime: device.lastTime || 0
                        };
                    })
                };
            });
            summary.groups = this.typeOrder.map(function (type) {
                return groupMap[type] || {
                    type: type,
                    typeLabel: self.typeLabels[type] || type,
                    deviceCount: 0,
                    totalFiles: 0,
                    devices: []
                };
            });
            return summary;
                },
                error: function () {
                    clearInterval(that.downloadTimer);
                    that.downloadDialogVisible = false;
                    that.$message.error('下载失败或未找到日志');
        pickActiveType: function () {
            var existing = this.activeType;
            if (existing) {
                var existingGroup = this.getGroup(existing);
                if (existingGroup && (existingGroup.devices || []).length) {
                    return existing;
                }
            }
            for (var i = 0; i < this.typeOrder.length; i++) {
                var group = this.getGroup(this.typeOrder[i]);
                if (group && (group.devices || []).length) {
                    return group.type;
                }
            }
            return this.typeOrder[0] || '';
        },
        ensureDeviceSelection: function () {
            var selected = this.selectedDeviceSummary;
            if (selected && selected.type === this.activeType) {
                return;
            }
            this.resetSelectionState();
        },
        selectTypeGroup: function (type) {
            if (!type) {
                return;
            }
            this.activeType = type;
            var current = this.selectedDeviceSummary;
            if (current && current.type === type) {
                return;
            }
            this.resetSelectionState();
        },
        getGroup: function (type) {
            var result = null;
            (this.deviceGroups || []).forEach(function (group) {
                if (group.type === type) {
                    result = group;
                }
            });
            return result;
        },
        // --- 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();
        resetSelectionState: function () {
            this.viewMode = 'picker';
            this.selectedType = '';
            this.selectedDeviceNo = '';
            this.activeDeviceKey = '';
            this.detailTab = 'logs';
            this.rawTab = 'wcs';
            this.timelineMeta = this.createEmptyTimeline();
            this.timelineLoading = false;
            this.logLoading = false;
            this.logLoadError = '';
            this.logRows = [];
            this.loadedOffsets = {};
            this.loadingOffsets = {};
            this.selectedTimestamp = 0;
            this.logWindowAnchorOffset = -1;
        },
        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)'
                    });
        selectDevice: function (device) {
            if (!device) {
                return;
                }
            } 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)'
                 });
            var nextKey = this.buildDeviceKey(device.type, device.deviceNo);
            if (this.activeDeviceKey === nextKey && this.logRows.length) {
                return;
            }
            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.
            this.pause();
            this.viewMode = 'viewer';
            this.activeType = device.type;
            this.selectedType = device.type;
            this.selectedDeviceNo = String(device.deviceNo);
            this.activeDeviceKey = nextKey;
            this.detailTab = 'raw';
            this.rawTab = 'wcs';
            this.timelineMeta = this.createEmptyTimeline();
            this.logRows = [];
            this.loadedOffsets = {};
            this.loadingOffsets = {};
            this.selectedTimestamp = 0;
            this.logLoadError = '';
            this.loadTimeline();
        },
        returnToSelector: function () {
            this.pause();
            this.resetSelectionState();
        },
        loadTimeline: function () {
            if (!this.selectedDay || !this.selectedType || !this.selectedDeviceNo) {
                return;
            }
            // NEW LOGIC: If seeking, try to find offset first
            if (this.seekTargetTime > 0 && this.needToSeekOffset && !this.seekingOffset) {
                this.seekingOffset = true;
            this.timelineLoading = true;
            this.logLoadError = '';
            var self = this;
                $.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 },
                url: baseUrl + '/deviceLog/day/' + this.selectedDay + '/timeline/auth',
                headers: { token: localStorage.getItem('token') },
                method: 'GET',
                data: {
                    type: this.selectedType,
                    deviceNo: this.selectedDeviceNo
                },
                    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);
                    self.timelineLoading = false;
                    if (res && res.code === 200) {
                        self.timelineMeta = self.normalizeTimeline(res.data);
                        if (self.timelineMeta.startTime) {
                            self.selectedTimestamp = self.timelineMeta.startTime;
                        }
                        if (self.timelineMeta.totalFiles > 0) {
                            self.loadSegmentWindow(0, {
                                initialize: true,
                                focusTimestamp: self.timelineMeta.startTime
                            });
                        }
                        } else {
                            // Fallback to sequential load if seek fails
                            that.seekingOffset = false;
                            that.needToSeekOffset = false;
                            that.loadMoreLogsSequential(loadingInstance);
                        self.timelineMeta = self.createEmptyTimeline();
                        self.logLoadError = (res && res.msg) || '读取时间轴失败';
                        self.$message.error(self.logLoadError);
                        }
                    },
                    error: function() {
                        that.seekingOffset = false;
                        that.needToSeekOffset = false;
                        that.loadMoreLogsSequential(loadingInstance);
                    self.timelineLoading = false;
                    self.timelineMeta = self.createEmptyTimeline();
                    self.logLoadError = '读取时间轴失败';
                    self.$message.error('读取时间轴失败');
                    }
                });
        },
        normalizeTimeline: function (data) {
            var timeline = this.createEmptyTimeline();
            if (!data) {
                return timeline;
            }
            timeline.type = data.type || this.selectedType;
            timeline.typeLabel = data.typeLabel || this.typeLabels[timeline.type] || timeline.type;
            timeline.deviceNo = String(data.deviceNo || this.selectedDeviceNo || '');
            timeline.startTime = data.startTime || 0;
            timeline.endTime = data.endTime || 0;
            timeline.totalFiles = data.totalFiles || 0;
            timeline.segments = (data.segments || []).map(function (segment) {
                return {
                    offset: segment.offset,
                    startTime: segment.startTime || 0,
                    endTime: segment.endTime || 0
                };
            }).sort(function (a, b) {
                return a.offset - b.offset;
            });
            if (!timeline.startTime && timeline.segments.length) {
                for (var i = 0; i < timeline.segments.length; i++) {
                    if (timeline.segments[i].startTime) {
                        timeline.startTime = timeline.segments[i].startTime;
                        break;
                    }
                }
            }
            if (!timeline.endTime && timeline.segments.length) {
                for (var j = timeline.segments.length - 1; j >= 0; j--) {
                    if (timeline.segments[j].endTime) {
                        timeline.endTime = timeline.segments[j].endTime;
                        break;
                    }
                }
            }
            return timeline;
        },
        loadSegmentWindow: function (offset, options) {
            options = options || {};
            if (offset == null || offset < 0 || offset >= (this.timelineMeta.totalFiles || 0)) {
                return;
            }
            this.loadMoreLogsSequential(loadingInstance);
        },
        loadMoreLogsSequential(loadingInstance) {
             let that = this;
             let currentLimit = this.seekTargetTime > 0 ? 10 : this.visLimit;
            if (this.loadedOffsets[String(offset)] || this.loadingOffsets[String(offset)]) {
                if (options.focusTimestamp) {
                    this.selectedTimestamp = options.focusTimestamp;
                    if (options.resetWindow !== false) {
                        this.pruneLogRowsAroundOffset(options.anchorOffset != null ? options.anchorOffset : offset);
                    }
                    if (options.scrollIntoView !== false) {
                        this.$nextTick(this.scrollCurrentRowIntoView);
                    }
                }
                return;
            }
            var self = this;
            var batchSize = 1;
            this.logLoading = true;
            this.$set(this.loadingOffsets, String(offset), true);
             $.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 },
                url: baseUrl + '/deviceLog/day/' + this.selectedDay + '/preview/auth',
                headers: { token: localStorage.getItem('token') },
                method: 'GET',
                data: {
                    type: this.selectedType,
                    deviceNo: this.selectedDeviceNo,
                    offset: offset,
                    limit: batchSize
                },
                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('数据已全部加载');
                    self.logLoading = false;
                    self.$delete(self.loadingOffsets, String(offset));
                    if (res && res.code === 200) {
                        self.markLoadedOffsets(offset, batchSize);
                        var decorated = self.decorateLogs(res.data || [], offset);
                        self.mergeLogRows(decorated);
                        if (options.resetWindow !== false) {
                            self.pruneLogRowsAroundOffset(options.anchorOffset != null ? options.anchorOffset : offset);
                            }
                        if (options.initialize && self.logRows.length) {
                            self.selectedTimestamp = self.logRows[0]._ts || self.timelineMeta.startTime;
                        } else if (options.focusTimestamp) {
                            self.selectedTimestamp = options.focusTimestamp;
                        } else if (!self.selectedTimestamp && self.logRows.length) {
                            self.selectedTimestamp = self.logRows[0]._ts;
                        }
                        if (!decorated.length && options.initialize && offset + batchSize < self.timelineMeta.totalFiles) {
                            self.loadSegmentWindow(offset + batchSize, options);
                            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]);
                                    });
                        if (options.scrollIntoView !== false) {
                            self.$nextTick(self.scrollCurrentRowIntoView);
                                }
                            } 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;
                        self.logLoadError = (res && res.msg) || '读取日志失败';
                        self.$message.error(self.logLoadError);
                    }
                },
                error: function() {
                    if (loadingInstance) loadingInstance.close();
                    that.loadingLogs = false;
                    that.seekTargetTime = 0;
                    that.$message.error('请求失败');
                    self.logLoading = false;
                    self.$delete(self.loadingOffsets, String(offset));
                    self.logLoadError = '读取日志失败';
                    self.$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]);
        markLoadedOffsets: function (offset, limit) {
            var total = this.timelineMeta.totalFiles || 0;
            for (var i = offset; i < Math.min(total, offset + limit); i++) {
                this.$set(this.loadedOffsets, String(i), true);
            }
        },
        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);
        decorateLogs: function (logs, segmentOffset) {
            var self = this;
            return (logs || []).map(function (logItem) {
                return self.decorateLog(logItem, segmentOffset);
            });
        },
        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();
                 }
            }
        decorateLog: function (logItem, segmentOffset) {
            var protocol = this.safeParse(logItem && logItem.wcsData);
            var visualItems = this.buildVisualItems(protocol, this.selectedType);
            return Object.assign({}, logItem, {
                _ts: this.parseTimestamp(logItem && logItem.createTime),
                _key: this.buildLogRowKey(logItem),
                _segmentOffset: segmentOffset,
                _protocol: protocol,
                _visualItems: visualItems,
                _visualParam: this.buildVisualParam(this.selectedType, this.selectedDeviceNo),
                _summary: this.buildLogSummary(this.selectedType, visualItems, protocol)
            });
        },
        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();
        buildLogSummary: function (type, visualItems, protocol) {
            var fallback = {
                statusLabel: '离线',
                tone: 'muted',
                title: '原始日志',
                detail: '当前记录缺少可解析的业务字段',
                hint: ''
            };
            if (!type) {
                return fallback;
            }
            if (type === 'Crn') {
                var crn = visualItems[0] || {};
                var crnStatus = MonitorCardKit.deviceStatusLabel(crn.deviceStatus);
                return {
                    statusLabel: crnStatus,
                    tone: MonitorCardKit.statusTone(crnStatus),
                    title: '任务 ' + MonitorCardKit.orDash(crn.workNo) + ' · 排 ' + MonitorCardKit.orDash(crn.bay) + ' · 层 ' + MonitorCardKit.orDash(crn.lev),
                    detail: '模式 ' + MonitorCardKit.orDash(crn.mode) + ' / 状态 ' + MonitorCardKit.orDash(crn.status) + ' / 货叉 ' + MonitorCardKit.orDash(crn.forkOffset),
                    hint: crn.warnCode ? ('报警代码 ' + crn.warnCode) : ('载货 ' + MonitorCardKit.orDash(crn.loading))
                };
            }
            if (type === 'DualCrn') {
                var dual = visualItems[0] || {};
                var dualStatus = MonitorCardKit.deviceStatusLabel(dual.deviceStatus);
                return {
                    statusLabel: dualStatus,
                    tone: MonitorCardKit.statusTone(dualStatus),
                    title: '工位1任务 ' + MonitorCardKit.orDash(dual.taskNo) + ' · 工位2任务 ' + MonitorCardKit.orDash(dual.taskNoTwo),
                    detail: '排 ' + MonitorCardKit.orDash(dual.bay) + ' / 层 ' + MonitorCardKit.orDash(dual.lev) + ' / 状态 ' + MonitorCardKit.orDash(dual.status),
                    hint: dual.warnCode ? ('报警代码 ' + dual.warnCode) : ('工位2状态 ' + MonitorCardKit.orDash(dual.statusTwo))
                };
            }
            if (type === 'Rgv') {
                var rgv = visualItems[0] || {};
                var rgvStatus = MonitorCardKit.deviceStatusLabel(rgv.deviceStatus);
                return {
                    statusLabel: rgvStatus,
                    tone: MonitorCardKit.statusTone(rgvStatus),
                    title: '任务 ' + MonitorCardKit.orDash(rgv.taskNo) + ' · 轨道位 ' + MonitorCardKit.orDash(rgv.trackSiteNo),
                    detail: '模式 ' + MonitorCardKit.orDash(rgv.mode) + ' / 状态 ' + MonitorCardKit.orDash(rgv.status) + ' / 载货 ' + MonitorCardKit.orDash(rgv.loading),
                    hint: rgv.warnCode ? ('报警代码 ' + rgv.warnCode) : ''
                };
            }
            if (type === 'Devp') {
                var stations = visualItems || [];
                var autoCount = 0;
                var taskCount = 0;
                var loadingCount = 0;
                var errorStations = [];
                var canInCount = 0;
                for (var i = 0; i < stations.length; i++) {
                    if (this.toBool(stations[i].autoing)) {
                        autoCount += 1;
                    }
                    if (stations[i].taskNo != null && stations[i].taskNo !== '' && Number(stations[i].taskNo) !== 0) {
                        taskCount += 1;
                    }
                    if (this.toBool(stations[i].loading)) {
                        loadingCount += 1;
                    }
                    if (this.toBool(stations[i].inEnable)) {
                        canInCount += 1;
                    }
                    if (stations[i].error || stations[i].errorMsg) {
                        errorStations.push(stations[i].stationId);
                 }
            }
                var statusLabel = errorStations.length ? '故障' : (autoCount === stations.length && stations.length ? '自动' : '手动');
                return {
                    statusLabel: statusLabel,
                    tone: MonitorCardKit.statusTone(statusLabel),
                    title: stations.length + ' 个站点 · 任务 ' + taskCount + ' · 有物 ' + loadingCount,
                    detail: '自动 ' + autoCount + ' / 手动 ' + Math.max(0, stations.length - autoCount) + ' / 可入 ' + canInCount,
                    hint: errorStations.length ? ('异常站点 ' + errorStations.slice(0, 6).join(', ')) : ('站点数组大小 ' + stations.length)
                };
            }
            return fallback;
        },
        syncState() {
            var idx = this.binarySearch(this.currentTime);
            if (idx >= 0) {
                var targetLog = this.logs[idx];
                this.updateDeviceState(targetLog);
        buildVisualItems: function (protocol, type) {
            if (!protocol) {
                return [];
            }
            if (type === 'Devp' && Array.isArray(protocol)) {
                var self = this;
                return protocol.map(function (item) {
                    return self.transformData(item, type);
                }).sort(function (a, b) {
                    return (a.stationId || 0) - (b.stationId || 0);
                });
            }
            if (type !== 'Devp') {
                return [this.transformData(protocol, type)];
            }
            return [];
        },
        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;
        buildVisualParam: function (type, deviceNo) {
            if (type === 'Crn' || type === 'DualCrn') {
                return { crnNo: Number(deviceNo) };
                }
            if (type === 'Rgv') {
                return { rgvNo: Number(deviceNo) };
            }
            return ans;
            return {};
        },
        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];
        transformData: function (protocol, type) {
            if (!protocol) {
                return {};
                }
                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 || {};
@@ -691,19 +879,20 @@
                    lev: protocol.level,
                    forkOffset: CrnForkPosType[protocol.forkPos] || '-',
                    liftPos: CrnLiftPosType[protocol.liftPos] || '-',
                    walkPos: (protocol.walkPos == 1) ? '不在定位' : '在定位',
                    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' :
                    deviceStatus: protocol.alarm && protocol.alarm > 0 ? 'ERROR' :
                                  ((protocol.taskNo && protocol.taskNo > 0) ? 'WORKING' : 
                                  (protocol.mode == 3 ? 'AUTO' : 'OFFLINE')) 
                };
            } else if (type === 'DualCrn') {
                 var vo = {
            }
            if (type === 'DualCrn') {
                var dual = {
                    crnNo: protocol.crnNo,
                    taskNo: protocol.taskNo || 0,
                    taskNoTwo: protocol.taskNoTwo || 0,
@@ -727,13 +916,14 @@
                    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 = {
                if (protocol.alarm && protocol.alarm > 0) dual.deviceStatus = 'ERROR';
                else if ((protocol.taskNo && protocol.taskNo > 0) || (protocol.taskNoTwo && protocol.taskNoTwo > 0)) dual.deviceStatus = 'WORKING';
                else if (protocol.mode == 3) dual.deviceStatus = 'AUTO';
                else dual.deviceStatus = 'OFFLINE';
                return dual;
            }
            if (type === 'Rgv') {
                var rgv = {
                     rgvNo: protocol.rgvNo,
                     taskNo: protocol.taskNo,
                     mode: RgvModeType[protocol.mode] || '',
@@ -742,15 +932,13 @@
                     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') {
                if (protocol.alarm && protocol.alarm > 0) rgv.deviceStatus = 'ERROR';
                else if (protocol.taskNo && protocol.taskNo > 0) rgv.deviceStatus = 'WORKING';
                else if (protocol.mode == 3) rgv.deviceStatus = 'AUTO';
                else rgv.deviceStatus = 'OFFLINE';
                return rgv;
            }
            if (type === 'Devp') {
                return {
                    stationId: protocol.stationId,
                    taskNo: protocol.taskNo,
@@ -773,93 +961,517 @@
            }
            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();
        safeParse: function (text) {
            if (!text) {
                return null;
            }
            try {
                return JSON.parse(text);
            } catch (e) {
                return null;
            }
        },
        initJumpTime() {
            if (this.currentTime > 0) {
                this.jumpTime = new Date(this.currentTime);
            } else if (this.startTime > 0) {
                this.jumpTime = new Date(this.startTime);
        mergeLogRows: function (newRows) {
            var map = {};
            var merged = [];
            this.logRows.concat(newRows || []).forEach(function (row) {
                if (!row || !row._key || map[row._key]) {
                    return;
                }
                map[row._key] = true;
                merged.push(row);
            });
            merged.sort(function (a, b) {
                return a._ts - b._ts;
            });
            this.logRows = merged;
        },
        pruneLogRowsAroundOffset: function (anchorOffset) {
            if (anchorOffset == null || anchorOffset < 0) {
                return;
            }
            var minOffset = Math.max(0, anchorOffset - this.loadedSegmentRadius);
            var maxOffset = anchorOffset + this.loadedSegmentRadius;
            var loadedKeys = Object.keys(this.loadedOffsets);
            if (this.logWindowAnchorOffset === anchorOffset && loadedKeys.length <= (this.loadedSegmentRadius * 2 + 1)) {
                return;
            }
            var nextLoaded = {};
            loadedKeys.forEach(function (key) {
                var offset = Number(key);
                if (offset >= minOffset && offset <= maxOffset) {
                    nextLoaded[key] = true;
                }
            });
            this.loadedOffsets = nextLoaded;
            this.logRows = (this.logRows || []).filter(function (row) {
                return row && row._segmentOffset >= minOffset && row._segmentOffset <= maxOffset;
            });
            this.logWindowAnchorOffset = anchorOffset;
        },
        buildLogRowKey: function (logItem) {
            return [
                this.selectedType,
                this.selectedDeviceNo,
                logItem && logItem.createTime ? logItem.createTime : '',
                this.hashString(logItem && logItem.originData ? logItem.originData : ''),
                this.hashString(logItem && logItem.wcsData ? logItem.wcsData : '')
            ].join('|');
        },
        hashString: function (text) {
            var str = String(text || '');
            var hash = 0;
            for (var i = 0; i < str.length; i++) {
                hash = ((hash << 5) - hash) + str.charCodeAt(i);
                hash |= 0;
            }
            return hash;
        },
        parseTimestamp: function (value) {
            var ts = new Date(value).getTime();
            return isNaN(ts) ? 0 : ts;
        },
        binarySearch: function (time) {
            var list = this.logRows;
            var left = 0;
            var right = list.length - 1;
            var answer = -1;
            while (left <= right) {
                var mid = Math.floor((left + right) / 2);
                if ((list[mid]._ts || 0) <= time) {
                    answer = mid;
                    left = mid + 1;
            } 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 {
                    right = mid - 1;
                }
            }
            return answer;
        },
        handleLogRowClick: function (row) {
            if (!row) {
                return;
            }
            this.pause();
            this.selectedTimestamp = row._ts;
            if (row._segmentOffset != null) {
                this.pruneLogRowsAroundOffset(row._segmentOffset);
            }
            this.$nextTick(this.scrollCurrentRowIntoView);
        },
        buildLogMetaLine: function (summary) {
            if (!summary) {
                return '';
            }
            return summary.detail + (summary.hint ? (' · ' + summary.hint) : '');
        },
        getWcsDataText: function (row) {
            if (!row || !row.wcsData) {
                return '当前记录没有 wcsData。';
            }
            if (row._protocol) {
                return this.prettyPrintJson(row._protocol);
            }
            return String(row.wcsData);
        },
        getOriginDataText: function (row) {
            if (!row || !row.originData) {
                return '当前记录没有 originData。';
            }
            var parsed = this.safeParse(row.originData);
            return parsed ? this.prettyPrintJson(parsed) : String(row.originData);
        },
        prettyPrintJson: function (value) {
            if (value == null || value === '') {
                return '';
            }
            try {
                if (typeof value === 'string') {
                    return JSON.stringify(JSON.parse(value), null, 2);
                }
                return JSON.stringify(value, null, 2);
            } catch (e) {
                return String(value);
            }
        },
        handleSliderInput: function (value) {
            if (!this.selectedDeviceSummary) {
                return;
            }
            var next = (this.timelineMeta.startTime || 0) + value;
            this.selectedTimestamp = next;
        },
        handleSliderChange: function (value) {
            if (!this.selectedDeviceSummary) {
                return;
            }
            var next = (this.timelineMeta.startTime || 0) + value;
            this.seekToTimestamp(next, { scrollIntoView: true });
        },
        ensureTimestampLoaded: function (timestamp) {
            if (!timestamp || !this.timelineMeta.segments.length) {
                return;
            }
            var segment = this.findSegmentByTime(timestamp);
            if (!segment) {
                return;
            }
            if (this.loadedOffsets[String(segment.offset)]) {
                this.pruneLogRowsAroundOffset(segment.offset);
            }
            if (!this.loadedOffsets[String(segment.offset)] && !this.loadingOffsets[String(segment.offset)]) {
                this.loadSegmentWindow(segment.offset, {
                    focusTimestamp: timestamp,
                    anchorOffset: segment.offset,
                    resetWindow: true,
                    scrollIntoView: false
                });
                return;
            }
            var nextOffset = segment.offset + 1;
            if (nextOffset < this.timelineMeta.totalFiles && !this.loadedOffsets[String(nextOffset)] && !this.loadingOffsets[String(nextOffset)]) {
                var segmentEnd = segment.endTime || this.timelineMeta.endTime;
                if (segmentEnd && timestamp >= segmentEnd - Math.max(2000, this.playbackSpeed * 500)) {
                    this.loadSegmentWindow(nextOffset, {
                        anchorOffset: segment.offset,
                        resetWindow: true,
                        scrollIntoView: false
                    });
                }
            }
        },
        findSegmentByTime: function (timestamp) {
            var segments = this.timelineMeta.segments || [];
            if (!segments.length) {
                return null;
            }
            var fallback = segments[0];
            for (var i = 0; i < segments.length; i++) {
                var segment = segments[i];
                var start = segment.startTime || (i === 0 ? this.timelineMeta.startTime : segments[i - 1].endTime);
                var end = segment.endTime || (i === segments.length - 1 ? this.timelineMeta.endTime : segments[i + 1].startTime);
                if (start && timestamp < start) {
                    return fallback;
                }
                fallback = segment;
                if ((!start || timestamp >= start) && (!end || timestamp <= end)) {
                    return segment;
                }
            }
            return fallback;
        },
        getLoadedOffsetNumbers: function () {
            var self = this;
            return Object.keys(this.loadedOffsets)
                .filter(function (key) { return !!self.loadedOffsets[key]; })
                .map(function (key) { return Number(key); })
                .sort(function (a, b) { return a - b; });
        },
        loadPreviousSegment: function () {
            if (!this.canLoadPreviousSegment) {
                return;
            }
            var offsets = this.getLoadedOffsetNumbers();
            if (!offsets.length) {
                this.loadSegmentWindow(0);
                return;
            }
            this.loadSegmentWindow(offsets[0] - 1);
        },
        loadNextSegment: function () {
            if (!this.canLoadNextSegment) {
                return;
            }
            var offsets = this.getLoadedOffsetNumbers();
            if (!offsets.length) {
                this.loadSegmentWindow(0);
                return;
            }
            this.loadSegmentWindow(offsets[offsets.length - 1] + 1);
        },
        play: function () {
            if (!this.canPlay) {
                return;
            }
            if (!this.selectedTimestamp) {
                this.selectedTimestamp = this.timelineMeta.startTime;
            }
            this.isPlaying = true;
            this.lastTick = Date.now();
            this.tick();
        },
        tick: function () {
            if (!this.isPlaying) {
                return;
            }
            var now = Date.now();
            var delta = Math.max(0, now - this.lastTick);
            this.lastTick = now;
            var endTime = this.timelineMeta.endTime || this.selectedTimestamp;
            if (!endTime) {
                this.pause();
                return;
            }
            var next = this.selectedTimestamp + delta * this.playbackSpeed;
            if (next >= endTime) {
                this.selectedTimestamp = endTime;
                this.ensureTimestampLoaded(endTime);
                this.pause();
                this.$nextTick(this.scrollCurrentRowIntoView);
                return;
            }
            this.selectedTimestamp = next;
            this.ensureTimestampLoaded(next);
            var self = this;
            this.timer = setTimeout(function () {
                self.tick();
            }, this.playbackTickMs);
        },
        pause: function () {
            this.isPlaying = false;
            if (this.timer) {
                clearTimeout(this.timer);
                cancelAnimationFrame(this.timer);
                this.timer = null;
            }
        },
        resetPlayback: function () {
            this.pause();
            if (this.timelineMeta.startTime) {
                this.selectedTimestamp = this.timelineMeta.startTime;
                this.ensureTimestampLoaded(this.selectedTimestamp);
                this.$nextTick(this.scrollCurrentRowIntoView);
            }
        },
        initJumpTime: function () {
            if (this.selectedTimestamp) {
                this.jumpTime = new Date(this.selectedTimestamp);
                return;
            }
            if (this.selectedDay && this.selectedDay.length === 8) {
                this.jumpTime = new Date(this.selectedDay.substring(0, 4) + '/' + this.selectedDay.substring(4, 6) + '/' + this.selectedDay.substring(6, 8) + ' 00:00:00');
                return;
            }
                    this.jumpTime = new Date();
        },
        confirmJump: function () {
            if (!this.jumpTime || !this.selectedDay || !this.timelineMeta.startTime) {
                return;
                }
            this.pause();
            var baseDate = new Date(this.selectedDay.substring(0, 4) + '/' + this.selectedDay.substring(4, 6) + '/' + this.selectedDay.substring(6, 8) + ' 00:00:00');
            var target = new Date(this.jumpTime);
            baseDate.setHours(target.getHours());
            baseDate.setMinutes(target.getMinutes());
            baseDate.setSeconds(target.getSeconds());
            baseDate.setMilliseconds(0);
            var ts = baseDate.getTime();
            if (this.timelineMeta.endTime && ts > this.timelineMeta.endTime) {
                ts = this.timelineMeta.endTime;
                this.$message.warning('目标时间超出日志范围,已跳转到结束时间');
            }
            if (ts < this.timelineMeta.startTime) {
                ts = this.timelineMeta.startTime;
                this.$message.warning('目标时间早于日志起点,已跳转到起始时间');
            }
            this.jumpVisible = false;
            this.seekToTimestamp(ts, { scrollIntoView: true });
        },
        seekToTimestamp: function (timestamp, options) {
            options = options || {};
            if (!timestamp || !this.selectedDay || !this.selectedType || !this.selectedDeviceNo) {
                return;
            }
            var self = this;
            this.selectedTimestamp = timestamp;
            $.ajax({
                url: baseUrl + '/deviceLog/day/' + this.selectedDay + '/seek/auth',
                headers: { token: localStorage.getItem('token') },
                method: 'GET',
                data: {
                    type: this.selectedType,
                    deviceNo: this.selectedDeviceNo,
                    timestamp: timestamp
                },
                success: function (res) {
                    if (res && res.code === 200 && res.data && res.data.offset != null) {
                        self.loadSegmentWindow(Number(res.data.offset), {
                            focusTimestamp: timestamp,
                            anchorOffset: Number(res.data.offset),
                            resetWindow: true,
                            scrollIntoView: options.scrollIntoView !== false
                        });
                        return;
                    }
                    self.ensureTimestampLoaded(timestamp);
                    if (options.scrollIntoView !== false) {
                        self.$nextTick(self.scrollCurrentRowIntoView);
            }
        },
        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('目标时间超出日志范围,已跳转至结束时间');
                error: function () {
                    self.ensureTimestampLoaded(timestamp);
                    if (options.scrollIntoView !== false) {
                        self.$nextTick(self.scrollCurrentRowIntoView);
                }
            }
            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();
            });
        },
        scrollCurrentRowIntoView: function () {
            if (!this.selectedLogKey) {
                return;
            }
            var el = document.getElementById('log-row-' + this.selectedLogKey);
            if (el && typeof el.scrollIntoView === 'function') {
                el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
            }
        },
        formatTooltip: function (value) {
            if (!this.timelineMeta.startTime) {
                return '--';
            }
            return this.formatTimestamp(this.timelineMeta.startTime + value, true);
        },
        handleCurrentDeviceDownload: function () {
            this.doDownload(this.selectedDay, this.selectedType, this.selectedDeviceNo);
        },
        doDownload: function (day, type, deviceNo) {
            if (!day || !type || !deviceNo) {
                return;
            }
            var self = this;
            $.ajax({
                url: baseUrl + '/deviceLog/download/init/auth',
                headers: { token: localStorage.getItem('token') },
                method: 'POST',
                data: JSON.stringify({
                    day: day,
                    type: type,
                    deviceNo: deviceNo
                }),
                dataType: 'json',
                contentType: 'application/json;charset=UTF-8',
                success: function (res) {
                    if (!res || res.code !== 200) {
                        self.$message.error((res && res.msg) || '初始化失败');
                        return;
                    }
                    var pid = res.data.progressId;
                    self.startDownloadProgress(pid);
                    self.performDownloadRequest(day, type, deviceNo, pid);
                },
                error: function () {
                    self.$message.error('初始化失败');
                }
            });
        },
        startDownloadProgress: function (pid) {
            if (this.downloadTimer) {
                clearInterval(this.downloadTimer);
                this.downloadTimer = null;
            }
            this.downloadDialogVisible = true;
            this.buildProgress = 0;
            this.receiveProgress = 0;
            var self = this;
            this.downloadTimer = setInterval(function () {
                $.ajax({
                    url: baseUrl + '/deviceLog/download/progress/auth',
                    headers: { token: localStorage.getItem('token') },
                    method: 'GET',
                    data: { id: pid },
                    success: function (res) {
                        if (res && res.code === 200) {
                            self.buildProgress = res.data.percent || 0;
                        }
                    }
                });
            }, 500);
        },
        performDownloadRequest: function (day, type, deviceNo, pid) {
            var self = this;
            $.ajax({
                url: baseUrl + '/deviceLog/day/' + day + '/download/auth?type=' + encodeURIComponent(type) + '&deviceNo=' + encodeURIComponent(deviceNo) + '&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) {
                            self.receiveProgress = Math.floor(e.loaded / e.total * 100);
                        }
                    };
                    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]);
                    }
                    self.buildProgress = 100;
                    self.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);
                    if (self.downloadTimer) {
                        clearInterval(self.downloadTimer);
                        self.downloadTimer = null;
                    }
                    setTimeout(function () {
                        self.downloadDialogVisible = false;
                    }, 900);
                },
                error: function () {
                    if (self.downloadTimer) {
                        clearInterval(self.downloadTimer);
                        self.downloadTimer = null;
                    }
                    self.downloadDialogVisible = false;
                    self.$message.error('下载失败或未找到日志');
                }
            });
        },
        buildDeviceKey: function (type, deviceNo) {
            return String(type || '') + ':' + String(deviceNo || '');
        },
        parseDeviceNo: function (deviceNo) {
            var n = parseInt(deviceNo, 10);
            return isNaN(n) ? Number.MAX_SAFE_INTEGER : n;
        },
        formatTimestamp: function (timestamp, withMillis) {
            if (!timestamp) {
                return '--';
            }
            var d = new Date(timestamp);
            if (isNaN(d.getTime())) {
                return '--';
            }
            var Y = d.getFullYear();
            var M = String(d.getMonth() + 1).padStart(2, '0');
            var D = String(d.getDate()).padStart(2, '0');
            var h = String(d.getHours()).padStart(2, '0');
            var m = String(d.getMinutes()).padStart(2, '0');
            var s = String(d.getSeconds()).padStart(2, '0');
            if (withMillis) {
                return Y + '-' + M + '-' + D + ' ' + h + ':' + m + ':' + s + '.' + String(d.getMilliseconds()).padStart(3, '0');
            }
            return Y + '-' + M + '-' + D + ' ' + h + ':' + m + ':' + s;
        },
        formatDayText: function (day) {
            if (!day || day.length !== 8) {
                return '--';
            }
            return day.substring(0, 4) + '-' + day.substring(4, 6) + '-' + day.substring(6, 8);
        },
        toBool: function (value) {
            return value === true || value === 'Y' || value === 'y' || value === 1 || value === '1';
        }
    }
});
src/main/webapp/views/deviceLogs/deviceLogs.html
@@ -6,192 +6,1346 @@
    <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">
    <!-- 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; }
        :root {
            --dl-bg: linear-gradient(180deg, #edf3f7 0%, #e7edf4 100%);
            --dl-panel-bg: rgba(248, 251, 253, 0.94);
            --dl-panel-border: rgba(223, 232, 240, 0.96);
            --dl-panel-shadow: 0 12px 26px rgba(114, 136, 164, 0.08);
            --dl-text-main: #22384f;
            --dl-text-sub: #708396;
            --dl-accent: #6f95bd;
            --dl-accent-strong: #557ca7;
            --dl-success: #52b17e;
            --dl-warning: #c78a3f;
            --dl-danger: #c96660;
        }
        
        .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; }
        html, body {
            width: 100%;
            height: 100%;
            margin: 0;
            overflow: hidden;
        }
        
        .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; }
        body {
            background: var(--dl-bg);
            color: var(--dl-text-main);
        }
        
        .control-bar { margin-bottom: 15px; padding: 15px; background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,21,41,.08); }
        #app {
            width: 100%;
            height: 100%;
        }
        
        /* 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; }
        .dl-shell {
            width: 100%;
            height: 100%;
            padding: 16px;
            box-sizing: border-box;
            display: flex;
            gap: 14px;
        }
        .dl-sidebar {
            width: 320px;
            min-width: 320px;
            display: flex;
            flex-direction: column;
            gap: 12px;
            min-height: 0;
        }
        .dl-workbench {
            flex: 1;
            min-width: 0;
            min-height: 0;
            display: grid;
            grid-template-columns: minmax(520px, 1.28fr) minmax(340px, 0.92fr);
            gap: 14px;
        }
        .dl-center,
        .dl-visual {
            min-width: 0;
            min-height: 0;
            display: flex;
            flex-direction: column;
            gap: 12px;
        }
        .dl-center {
            order: 2;
        }
        .dl-visual {
            order: 1;
        }
        .dl-panel {
            border-radius: 20px;
            border: 1px solid var(--dl-panel-border);
            background: var(--dl-panel-bg);
            box-shadow: var(--dl-panel-shadow);
            overflow: hidden;
            min-height: 0;
        }
        .dl-panel-head {
            padding: 14px 16px 10px;
            border-bottom: 1px solid rgba(228, 236, 243, 0.92);
            background: rgba(255, 255, 255, 0.26);
        }
        .dl-panel-title {
            font-size: 15px;
            font-weight: 700;
            color: var(--dl-text-main);
            line-height: 1.25;
        }
        .dl-panel-desc {
            margin-top: 5px;
            font-size: 12px;
            color: var(--dl-text-sub);
            line-height: 1.5;
        }
        .dl-panel-body {
            padding: 14px 16px 16px;
            box-sizing: border-box;
            min-height: 0;
            height: 100%;
        }
        .dl-hero {
            padding: 16px;
            background: linear-gradient(135deg, rgba(255, 255, 255, 0.88) 0%, rgba(240, 246, 251, 0.82) 100%);
        }
        .dl-hero-title {
            font-size: 19px;
            font-weight: 700;
            color: var(--dl-text-main);
            line-height: 1.2;
        }
        .dl-hero-desc {
            margin-top: 8px;
            font-size: 12px;
            line-height: 1.6;
            color: var(--dl-text-sub);
        }
        .dl-hero-stats {
            margin-top: 14px;
            display: grid;
            grid-template-columns: repeat(3, minmax(0, 1fr));
            gap: 10px;
        }
        .dl-stat {
            padding: 10px 12px;
            border-radius: 14px;
            border: 1px solid rgba(225, 233, 240, 0.94);
            background: rgba(255, 255, 255, 0.7);
        }
        .dl-stat-value {
            font-size: 18px;
            font-weight: 700;
            color: var(--dl-accent-strong);
        }
        .dl-stat-label {
            margin-top: 4px;
            font-size: 11px;
            color: var(--dl-text-sub);
        }
        .dl-date-panel,
        .dl-device-panel,
        .dl-log-panel,
        .dl-raw-panel,
        .dl-visual-card,
        .dl-device-summary,
        .dl-timeline-panel {
            display: flex;
            flex-direction: column;
            min-height: 0;
        }
        .dl-date-panel {
            flex: 0 0 260px;
        }
        .dl-device-panel {
            flex: 1;
        }
        .dl-quick-days {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            margin-bottom: 14px;
        }
        .dl-day-chip {
            padding: 0 10px;
            height: 30px;
            border-radius: 999px;
            border: 1px solid rgba(219, 228, 236, 0.96);
            background: rgba(255, 255, 255, 0.84);
            color: #47627d;
            cursor: pointer;
            transition: all .16s ease;
        }
        .dl-day-chip.is-active {
            border-color: rgba(111, 149, 189, 0.44);
            background: rgba(111, 149, 189, 0.14);
            color: var(--dl-accent-strong);
            box-shadow: 0 8px 18px rgba(111, 149, 189, 0.16);
        }
        .dl-tree-wrap {
            flex: 1;
            min-height: 0;
            overflow: auto;
            padding-right: 4px;
            scrollbar-gutter: stable;
        }
        .dl-type-tabs {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 8px;
            margin-bottom: 12px;
        }
        .dl-type-tab {
            padding: 10px 12px;
            border-radius: 14px;
            border: 1px solid rgba(222, 231, 239, 0.96);
            background: rgba(255, 255, 255, 0.72);
            cursor: pointer;
            text-align: left;
            transition: all .16s ease;
        }
        .dl-type-tab.is-active {
            border-color: rgba(111, 149, 189, 0.44);
            background: rgba(111, 149, 189, 0.14);
            box-shadow: 0 10px 20px rgba(111, 149, 189, 0.12);
        }
        .dl-type-tab-label {
            font-size: 13px;
            font-weight: 700;
            color: var(--dl-text-main);
        }
        .dl-type-tab-meta {
            margin-top: 4px;
            font-size: 11px;
            color: var(--dl-text-sub);
        }
        .dl-device-search {
            margin-bottom: 12px;
        }
        .dl-device-list {
            flex: 1;
            min-height: 0;
            overflow: auto;
            padding-right: 4px;
            display: flex;
            flex-direction: column;
            gap: 10px;
            scrollbar-gutter: stable;
        }
        .dl-device-item {
            padding: 12px 13px;
            border-radius: 16px;
            border: 1px solid rgba(223, 232, 240, 0.94);
            background: rgba(255, 255, 255, 0.72);
            cursor: pointer;
            transition: all .16s ease;
        }
        .dl-device-item.is-active {
            border-color: rgba(111, 149, 189, 0.48);
            background: rgba(111, 149, 189, 0.12);
            box-shadow: 0 12px 24px rgba(111, 149, 189, 0.14);
        }
        .dl-device-item:hover {
            transform: translateY(-1px);
        }
        .dl-device-name {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
            font-size: 14px;
            font-weight: 700;
            color: var(--dl-text-main);
        }
        .dl-device-badge {
            padding: 3px 8px;
            border-radius: 999px;
            background: rgba(111, 149, 189, 0.12);
            color: var(--dl-accent-strong);
            font-size: 10px;
            font-weight: 700;
        }
        .dl-device-meta {
            margin-top: 8px;
            display: flex;
            flex-direction: column;
            gap: 4px;
            font-size: 11px;
            color: var(--dl-text-sub);
        }
        .dl-date-panel .dl-panel-body,
        .dl-device-panel .dl-panel-body {
            display: flex;
            flex-direction: column;
            flex: 1;
            min-height: 0;
            height: auto;
        }
        .dl-device-summary .dl-panel-head {
            padding: 12px 16px 8px;
        }
        .dl-device-summary .dl-panel-desc {
            margin-top: 3px;
        }
        .dl-device-summary .dl-panel-body,
        .dl-timeline-panel .dl-panel-body {
            padding: 12px 16px 14px;
        }
        .dl-timeline-panel .dl-panel-head {
            padding: 8px 14px 4px;
        }
        .dl-timeline-panel .dl-panel-desc {
            display: none;
        }
        .dl-timeline-panel .dl-panel-body {
            padding: 8px 14px 10px;
        }
        .dl-timeline-panel .dl-btn {
            height: 30px;
            padding: 0 12px;
            border-radius: 10px;
        }
        .dl-raw-panel .dl-panel-body {
            display: flex;
            flex-direction: column;
            flex: 1;
            min-height: 0;
            height: auto;
        }
        .dl-summary-toolbar {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 10px;
            margin-bottom: 10px;
        }
        .dl-device-summary .dl-btn {
            height: 30px;
            padding: 0 12px;
            border-radius: 10px;
        }
        .dl-summary-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 6px;
        }
        .dl-summary-cell {
            padding: 9px 10px;
            border-radius: 12px;
            border: 1px solid rgba(224, 232, 239, 0.96);
            background: rgba(255, 255, 255, 0.78);
        }
        .dl-summary-cell-label {
            font-size: 10px;
            line-height: 1.2;
            color: var(--dl-text-sub);
        }
        .dl-summary-cell-value {
            margin-top: 4px;
            font-size: 12px;
            font-weight: 700;
            color: var(--dl-text-main);
            line-height: 1.35;
            word-break: normal;
            overflow-wrap: anywhere;
        }
        .dl-summary-cell-sub {
            margin-top: 2px;
            font-size: 11px;
            line-height: 1.3;
            color: var(--dl-text-sub);
        }
        .dl-summary-cell.is-range,
        .dl-summary-cell.is-current {
            grid-column: 1 / -1;
        }
        .dl-summary-cell.is-range .dl-summary-cell-value,
        .dl-summary-cell.is-range .dl-summary-cell-sub {
            font-family: monospace;
        }
        .dl-inline-actions {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
        }
        .dl-btn {
            height: 34px;
            padding: 0 14px;
            border-radius: 12px;
            border: none;
            background: var(--dl-accent);
            color: #fff;
            font-size: 12px;
            font-weight: 700;
            cursor: pointer;
            transition: transform .16s ease, box-shadow .16s ease;
            box-shadow: 0 10px 18px rgba(111, 149, 189, 0.16);
        }
        .dl-btn:hover {
            transform: translateY(-1px);
        }
        .dl-btn.is-ghost {
            border: 1px solid rgba(219, 228, 236, 0.96);
            background: rgba(255, 255, 255, 0.84);
            color: #4b667f;
            box-shadow: none;
        }
        .dl-btn[disabled] {
            opacity: .5;
            cursor: not-allowed;
            transform: none;
            box-shadow: none;
        }
        .dl-log-workspace {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
            gap: 12px;
        }
        .dl-detail-panel {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
        }
        .dl-detail-toolbar {
            padding: 14px 16px 10px;
            border-bottom: 1px solid rgba(228, 236, 243, 0.92);
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 10px;
        }
        .dl-detail-tabs {
            display: inline-flex;
            gap: 8px;
            flex-wrap: wrap;
            justify-content: flex-end;
        }
        .dl-detail-body {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
        }
        .dl-log-status {
            display: inline-flex;
            align-items: center;
            height: 24px;
            padding: 0 9px;
            border-radius: 999px;
            font-size: 11px;
            font-weight: 700;
            margin-right: 8px;
        }
        .dl-tone-success { background: rgba(82, 177, 126, 0.12); color: #2d7650; }
        .dl-tone-working { background: rgba(111, 149, 189, 0.12); color: #3f6286; }
        .dl-tone-warning { background: rgba(214, 162, 94, 0.14); color: #9b6a24; }
        .dl-tone-danger { background: rgba(201, 102, 96, 0.14); color: #a74d47; }
        .dl-tone-muted { background: rgba(148, 163, 184, 0.14); color: #748397; }
        .dl-log-list {
            flex: 1;
            min-height: 0;
            overflow: auto;
            padding: 12px 16px 16px;
            display: flex;
            flex-direction: column;
            gap: 10px;
            scrollbar-gutter: stable;
        }
        .dl-raw-content {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
            padding: 12px 16px 16px;
            box-sizing: border-box;
        }
        .dl-log-row {
            padding: 10px 12px;
            border-radius: 14px;
            border: 1px solid rgba(223, 232, 240, 0.94);
            background: rgba(255, 255, 255, 0.72);
            cursor: pointer;
            transition: all .16s ease;
        }
        .dl-log-row.is-active {
            border-color: rgba(111, 149, 189, 0.5);
            background: rgba(111, 149, 189, 0.12);
            box-shadow: 0 12px 22px rgba(111, 149, 189, 0.12);
        }
        .dl-log-row-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
        }
        .dl-log-time {
            font-size: 12px;
            font-weight: 700;
            color: var(--dl-accent-strong);
            font-family: monospace;
        }
        .dl-log-title {
            margin-top: 6px;
            font-size: 12px;
            font-weight: 700;
            color: var(--dl-text-main);
            line-height: 1.35;
        }
        .dl-log-meta-line {
            margin-top: 4px;
            font-size: 11px;
            line-height: 1.4;
            color: var(--dl-text-sub);
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .dl-log-hint {
            margin-top: 5px;
            display: inline-flex;
            align-items: center;
            font-size: 11px;
            color: var(--dl-text-sub);
        }
        .dl-picker-main {
            flex: 1;
            min-width: 0;
            min-height: 0;
            display: flex;
            flex-direction: column;
        }
        .dl-picker-panel {
            flex: 1;
            min-height: 0;
            display: flex;
            flex-direction: column;
        }
        .dl-picker-panel .dl-panel-body {
            display: flex;
            flex-direction: column;
            min-height: 0;
            height: auto;
        }
        .dl-picker-toolbar {
            display: grid;
            grid-template-columns: minmax(0, 1fr) 280px;
            gap: 12px;
            align-items: start;
            margin-bottom: 12px;
        }
        .dl-picker-side-tools {
            display: flex;
            flex-direction: column;
            gap: 10px;
        }
        .dl-picker-current-day {
            padding: 10px 12px;
            border-radius: 14px;
            border: 1px solid rgba(223, 232, 240, 0.94);
            background: rgba(255, 255, 255, 0.72);
            font-size: 12px;
            color: var(--dl-text-sub);
        }
        .dl-picker-current-day strong {
            display: block;
            margin-top: 3px;
            font-size: 13px;
            color: var(--dl-text-main);
        }
        .dl-picker-device-grid {
            flex: 1;
            min-height: 0;
            overflow: auto;
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
            gap: 12px;
            padding-right: 4px;
            scrollbar-gutter: stable;
        }
        .dl-picker-device-grid .dl-device-item {
            min-height: 112px;
            text-align: left;
        }
        .dl-picker-span-all {
            grid-column: 1 / -1;
        }
        .dl-viewer-shell {
            flex: 1;
            min-width: 0;
            min-height: 0;
            display: flex;
            flex-direction: column;
            gap: 12px;
        }
        .dl-viewer-header .dl-panel-body {
            height: auto;
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            padding: 12px 16px;
        }
        .dl-viewer-header-main {
            min-width: 0;
            display: flex;
            align-items: flex-start;
            gap: 12px;
        }
        .dl-viewer-copy {
            min-width: 0;
            display: flex;
            flex-direction: column;
            gap: 6px;
        }
        .dl-viewer-meta {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
        }
        .dl-viewer-meta-item {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 5px 10px;
            border-radius: 999px;
            border: 1px solid rgba(223, 232, 240, 0.94);
            background: rgba(255, 255, 255, 0.72);
            font-size: 11px;
            color: var(--dl-text-sub);
        }
        .dl-viewer-meta-item strong {
            color: var(--dl-text-main);
            font-weight: 700;
        }
        .dl-viewer-grid {
            flex: 1;
            min-height: 0;
            display: grid;
            grid-template-columns: minmax(620px, 1.28fr) minmax(360px, 0.92fr);
            gap: 12px;
        }
        .dl-viewer-main,
        .dl-viewer-side {
            min-width: 0;
            min-height: 0;
            display: flex;
            flex-direction: column;
            gap: 10px;
        }
        .dl-viewer-side .dl-timeline-panel {
            flex: 0 0 auto;
        }
        .dl-raw-toolbar {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            margin-bottom: 12px;
        }
        .dl-raw-meta {
            display: flex;
            flex-wrap: wrap;
            gap: 8px 12px;
            font-size: 12px;
            color: var(--dl-text-sub);
        }
        .dl-raw-tabs {
            display: inline-flex;
            gap: 8px;
        }
        .dl-tab-btn {
            height: 30px;
            padding: 0 12px;
            border-radius: 999px;
            border: 1px solid rgba(219, 228, 236, 0.96);
            background: rgba(255, 255, 255, 0.84);
            color: #4b667f;
            font-size: 12px;
            font-weight: 700;
            cursor: pointer;
            transition: all .16s ease;
        }
        .dl-tab-btn.is-active {
            border-color: rgba(111, 149, 189, 0.44);
            background: rgba(111, 149, 189, 0.14);
            color: var(--dl-accent-strong);
        }
        .dl-json-box {
            flex: 1;
            min-height: 0;
            overflow: auto;
            padding: 14px;
            border-radius: 16px;
            border: 1px solid rgba(223, 232, 240, 0.94);
            background: rgba(244, 248, 252, 0.88);
            scrollbar-gutter: stable;
        }
        .dl-json-pre {
            margin: 0;
            font-size: 12px;
            line-height: 1.55;
            color: #2b425a;
            font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
            white-space: pre-wrap;
            word-break: break-word;
        }
        .dl-json-note {
            margin-top: 8px;
            font-size: 11px;
            color: var(--dl-text-sub);
        }
        .dl-timeline-toolbar {
            display: grid;
            grid-template-columns: auto minmax(0, 1fr) auto;
            align-items: center;
            gap: 8px 10px;
            margin-bottom: 6px;
        }
        .dl-timeline-range {
            display: flex;
            align-items: center;
            gap: 10px;
            min-width: 0;
        }
        .dl-timeline-range .el-slider {
            flex: 1 1 220px;
            width: 100%;
            min-width: 180px;
        }
        .dl-time-readout {
            min-width: 196px;
            font-size: 13px;
            font-weight: 700;
            color: var(--dl-text-main);
            font-family: monospace;
            text-align: right;
            flex-shrink: 0;
        }
        .dl-timeline-side {
            display: inline-flex;
            align-items: center;
            justify-content: flex-end;
            gap: 8px;
            min-width: 0;
        }
        .dl-range-meta {
            margin-top: 4px;
            display: flex;
            flex-wrap: wrap;
            gap: 4px 10px;
            font-size: 11px;
            color: var(--dl-text-sub);
        }
        .dl-visual-card {
            flex: 1;
            min-height: 0;
        }
        .dl-visual-card .dl-panel-head {
            padding: 12px 16px 8px;
        }
        .dl-visual-card .dl-panel-body {
            height: 100%;
            min-height: 0;
            display: flex;
            flex-direction: column;
            overflow: hidden;
            padding: 10px 12px 12px;
        }
        .dl-visual-card .mc-toolbar {
            margin-bottom: 6px;
        }
        .dl-visual-card .mc-title {
            font-size: 13px;
        }
        .dl-visual-card .mc-head {
            padding: 8px 10px;
        }
        .dl-visual-card .mc-head-title {
            font-size: 12px;
        }
        .dl-visual-card .mc-head-subtitle {
            display: none;
        }
        .dl-visual-card .mc-body {
            padding: 0 10px 8px;
        }
        .dl-visual-card .mc-detail-grid {
            grid-template-columns: repeat(4, minmax(0, 1fr));
            gap: 5px;
            margin-top: 6px;
        }
        .dl-visual-card .mc-detail-cell {
            padding: 7px 8px;
            border-radius: 9px;
        }
        .dl-visual-card .mc-detail-label {
            font-size: 10px;
        }
        .dl-visual-card .mc-detail-value {
            margin-top: 2px;
            font-size: 10px;
            line-height: 1.25;
        }
        .dl-visual-card .mc-footer {
            margin-top: 6px;
        }
        .dl-tree-wrap,
        .dl-device-list,
        .dl-log-list,
        .dl-json-box,
        .dl-visual-card .mc-collapse {
            scrollbar-width: auto;
            scrollbar-color: rgba(111, 149, 189, 0.9) rgba(225, 233, 241, 0.72);
        }
        .dl-visual-card .mc-collapse {
            padding-right: 6px;
            scrollbar-gutter: stable;
        }
        .dl-tree-wrap::-webkit-scrollbar,
        .dl-device-list::-webkit-scrollbar,
        .dl-log-list::-webkit-scrollbar,
        .dl-json-box::-webkit-scrollbar,
        .dl-visual-card .mc-collapse::-webkit-scrollbar {
            width: 12px;
            height: 12px;
        }
        .dl-tree-wrap::-webkit-scrollbar-track,
        .dl-device-list::-webkit-scrollbar-track,
        .dl-log-list::-webkit-scrollbar-track,
        .dl-json-box::-webkit-scrollbar-track,
        .dl-visual-card .mc-collapse::-webkit-scrollbar-track {
            background: rgba(225, 233, 241, 0.78);
            border-radius: 999px;
            border: 2px solid rgba(248, 251, 253, 0.96);
        }
        .dl-tree-wrap::-webkit-scrollbar-thumb,
        .dl-device-list::-webkit-scrollbar-thumb,
        .dl-log-list::-webkit-scrollbar-thumb,
        .dl-json-box::-webkit-scrollbar-thumb,
        .dl-visual-card .mc-collapse::-webkit-scrollbar-thumb {
            background: linear-gradient(180deg, rgba(111, 149, 189, 0.96) 0%, rgba(85, 124, 167, 0.96) 100%);
            border-radius: 999px;
            border: 2px solid rgba(248, 251, 253, 0.96);
        }
        .dl-tree-wrap::-webkit-scrollbar-thumb:hover,
        .dl-device-list::-webkit-scrollbar-thumb:hover,
        .dl-log-list::-webkit-scrollbar-thumb:hover,
        .dl-json-box::-webkit-scrollbar-thumb:hover,
        .dl-visual-card .mc-collapse::-webkit-scrollbar-thumb:hover {
            background: linear-gradient(180deg, rgba(98, 136, 177, 0.98) 0%, rgba(74, 112, 154, 0.98) 100%);
        }
        .dl-empty,
        .dl-loading {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 100%;
            min-height: 160px;
            color: var(--dl-text-sub);
            font-size: 13px;
            text-align: center;
            padding: 0 20px;
            box-sizing: border-box;
        }
        .dl-loading i {
            font-size: 22px;
            margin-right: 8px;
        }
        @media (max-width: 1440px) {
            .dl-workbench {
                grid-template-columns: minmax(460px, 1.18fr) minmax(320px, 0.92fr);
            }
            .dl-picker-toolbar {
                grid-template-columns: 1fr;
            }
            .dl-viewer-grid {
                grid-template-columns: minmax(520px, 1.15fr) minmax(320px, 0.92fr);
            }
        }
        @media (max-width: 1180px) {
            .dl-workbench {
                grid-template-columns: 1fr;
            }
            .dl-center,
            .dl-visual {
                order: initial;
            }
            .dl-summary-grid,
            .dl-visual-card .mc-detail-grid {
                grid-template-columns: repeat(2, minmax(0, 1fr));
            }
            .dl-viewer-grid {
                grid-template-columns: 1fr;
            }
        }
        @media (max-width: 1560px) {
            .dl-timeline-toolbar {
                grid-template-columns: auto auto;
                align-items: center;
                justify-content: space-between;
            }
            .dl-timeline-range {
                grid-column: 1 / -1;
                width: 100%;
            }
            .dl-timeline-side {
                justify-content: flex-end;
            }
        }
        @media (max-width: 1320px) {
            .dl-timeline-range {
                flex-wrap: wrap;
            }
            .dl-time-readout {
                min-width: 0;
                width: 100%;
                text-align: left;
            }
        }
    </style>
</head>
<body>
<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>
    <div class="dl-shell">
        <template v-if="viewMode === 'picker'">
            <aside class="dl-sidebar">
                <section class="dl-panel dl-hero">
                    <div class="dl-hero-title">设备日志工作台</div>
                    <div class="dl-hero-desc">先选择日志日期和设备,再进入独立的数据查看页。筛选入口和查看态拆开,避免设备列表被数据看板挤压。</div>
                    <div class="dl-hero-stats">
                        <div class="dl-stat">
                            <div class="dl-stat-value">{{ summaryStats.totalDevices || 0 }}</div>
                            <div class="dl-stat-label">设备数</div>
                        </div>
                        <div class="dl-stat">
                            <div class="dl-stat-value">{{ summaryStats.totalFiles || 0 }}</div>
                            <div class="dl-stat-label">日志文件</div>
                        </div>
                        <div class="dl-stat">
                            <div class="dl-stat-value">{{ recentDays.length }}</div>
                            <div class="dl-stat-label">最近日期</div>
                        </div>
                    </div>
                </section>
                <section class="dl-panel dl-date-panel">
                    <div class="dl-panel-head">
                        <div class="dl-panel-title">日期导航</div>
                        <div class="dl-panel-desc">先选日志日期,再进入设备选择页。</div>
                    </div>
                    <div class="dl-panel-body">
                        <div class="dl-quick-days">
                            <button
                                v-for="day in recentDays"
                                :key="day.day"
                                type="button"
                                class="dl-day-chip"
                                :class="{ 'is-active': selectedDay === day.day }"
                                @click="handleRecentDayClick(day.day)"
                            >{{ day.label }}</button>
                        </div>
                        <div class="dl-tree-wrap">
                <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 }">
                                accordion
                                @node-click="handleNodeClick">
                                <span 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>
                                    <i v-else class="el-icon-date"></i>
                                    <span style="margin-left: 6px;">{{ node.label }}</span>
                    </span>
                </el-tree>
            </el-card>
        </div>
                    </div>
                </section>
            </aside>
        <!-- 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>
            <section class="dl-picker-main">
                <section class="dl-panel dl-picker-panel">
                    <div class="dl-panel-head">
                        <div class="dl-panel-title">设备选择</div>
                        <div class="dl-panel-desc">{{ selectedDay ? ('日志日期 ' + formatDayText(selectedDay) + ',先筛选设备类型,再点击一台设备进入状态查看页。') : '先从左侧选择一个日志日期。' }}</div>
            </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 class="dl-panel-body">
                        <div class="dl-picker-toolbar">
                            <div class="dl-type-tabs">
                                <button
                                    v-for="group in deviceGroups"
                                    :key="group.type"
                                    type="button"
                                    class="dl-type-tab"
                                    :class="{ 'is-active': activeType === group.type }"
                                    @click="selectTypeGroup(group.type)">
                                    <div class="dl-type-tab-label">{{ group.typeLabel }}</div>
                                    <div class="dl-type-tab-meta">{{ group.deviceCount }} 台 / {{ group.totalFiles }} 文件</div>
                                </button>
                </div>
                <div v-if="loading" style="text-align: center; padding: 20px;">
                    <i class="el-icon-loading" style="font-size: 24px;"></i>
                            <div class="dl-picker-side-tools">
                                <div class="dl-picker-current-day">
                                    当前日期
                                    <strong>{{ selectedDay ? formatDayText(selectedDay) : '未选择' }}</strong>
                </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 class="dl-device-search" style="margin-bottom: 0;">
                                    <el-input
                                        v-model.trim="searchDeviceNo"
                                        size="small"
                                        clearable
                                        placeholder="按设备编号筛选">
                                        <i slot="prefix" class="el-input__icon el-icon-search"></i>
                                    </el-input>
                </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>
                        </div>
                        <div class="dl-picker-device-grid">
                            <div v-if="summaryLoading" class="dl-loading dl-picker-span-all">
                                <i class="el-icon-loading"></i><span>正在加载设备摘要...</span>
                            </div>
                            <div v-else-if="!selectedDay" class="dl-empty dl-picker-span-all">先从左侧选择一个日期。</div>
                            <div v-else-if="filteredDevices.length === 0" class="dl-empty dl-picker-span-all">当前分组下没有匹配的设备。</div>
                            <button
                                v-else
                                v-for="device in filteredDevices"
                                :key="buildDeviceKey(device.type, device.deviceNo)"
                                type="button"
                                class="dl-device-item"
                                @click="selectDevice(device)">
                                <div class="dl-device-name">
                                    <span>{{ device.deviceNo }} 号{{ device.typeLabel }}</span>
                                    <span class="dl-device-badge">{{ device.fileCount }} 文件</span>
                                </div>
                                <div class="dl-device-meta">
                                    <span>首条: {{ formatTimestamp(device.firstTime, false) }}</span>
                                    <span>末条: {{ formatTimestamp(device.lastTime, false) }}</span>
                                </div>
                            </button>
                        </div>
                    </div>
                </section>
            </section>
                                    </template>
                                </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>
        <section v-else class="dl-viewer-shell">
            <section class="dl-panel dl-viewer-header">
                <div class="dl-panel-body">
                    <div class="dl-viewer-header-main">
                        <button type="button" class="dl-btn is-ghost" @click="returnToSelector">返回筛选</button>
                        <div class="dl-viewer-copy">
                            <div class="dl-panel-title">{{ selectedDeviceSummary ? (selectedDeviceSummary.typeLabel + ' ' + selectedDeviceSummary.deviceNo + '号') : '设备状态查看' }}</div>
                            <div class="dl-panel-desc">{{ selectedDay ? ('日志日期 ' + formatDayText(selectedDay)) : '请选择日期和设备' }}</div>
                            <div v-if="selectedDeviceSummary" class="dl-viewer-meta">
                                <span class="dl-viewer-meta-item">类型 <strong>{{ selectedDeviceSummary.typeLabel }}</strong></span>
                                <span class="dl-viewer-meta-item">文件 <strong>{{ selectedDeviceSummary.fileCount }}</strong></span>
                                <span class="dl-viewer-meta-item">已载 <strong>{{ loadedSegmentCount }}</strong></span>
                                <span class="dl-viewer-meta-item">范围 <strong>{{ timelineRangeText }}</strong></span>
            </div>
            <div style="width: 210px; font-size: 14px; font-weight: bold; font-family: monospace; display: flex; align-items: center;">
                {{ currentTimeStr }}
                        </div>
                    </div>
                    <div class="dl-inline-actions">
                        <span class="dl-log-status" :class="'dl-tone-' + currentStatusTone">{{ currentStatusLabel }}</span>
                        <button type="button" class="dl-btn is-ghost" @click="loadPreviousSegment" :disabled="!canLoadPreviousSegment">加载更早片段</button>
                        <button type="button" class="dl-btn is-ghost" @click="loadNextSegment" :disabled="!canLoadNextSegment">加载更新片段</button>
                        <button type="button" class="dl-btn" @click="handleCurrentDeviceDownload" :disabled="!canDownload">下载当日日志</button>
                    </div>
                </div>
            </section>
            <div class="dl-viewer-grid">
                <section class="dl-viewer-main">
                    <section class="dl-panel dl-visual-card">
                        <div class="dl-panel-head">
                            <div class="dl-panel-title">状态可视化</div>
                            <div class="dl-panel-desc">{{ selectedLogRow ? selectedLogRow._summary.detail : '拖动时间轴或点击日志后,在这里查看该时刻的设备状态。' }}</div>
                        </div>
                        <div class="dl-panel-body">
                            <component
                                v-if="visualComponentName"
                                :is="visualComponentName"
                                :key="activeDeviceKey"
                                :items="visualItems"
                                :param="visualParam"
                                :auto-refresh="false"
                                :read-only="true"></component>
                            <div v-else class="dl-empty">先选择一个设备,并加载至少一条日志记录。</div>
                        </div>
                    </section>
                </section>
                <section class="dl-viewer-side">
                    <section class="dl-panel dl-timeline-panel">
                        <div class="dl-panel-head">
                            <div class="dl-panel-title">时间轴与回放</div>
                            <div class="dl-panel-desc">拖动时间轴或跳转时间点查看该设备的状态变化。</div>
                        </div>
                        <div class="dl-panel-body">
                            <div class="dl-timeline-toolbar">
                                <div class="dl-inline-actions">
                                    <button type="button" class="dl-btn" v-if="!isPlaying" @click="play" :disabled="!canPlay">播放</button>
                                    <button type="button" class="dl-btn" v-else @click="pause">暂停</button>
                                    <button type="button" class="dl-btn is-ghost" @click="resetPlayback" :disabled="!selectedDeviceSummary">重置</button>
                                </div>
                                <div class="dl-timeline-range">
                                    <el-slider
                                        :value="sliderValue"
                                        :max="sliderMax"
                                        :disabled="!selectedDeviceSummary || sliderMax <= 0"
                                        @input="handleSliderInput"
                                        @change="handleSliderChange"
                                        :format-tooltip="formatTooltip"></el-slider>
                                    <div class="dl-time-readout">{{ currentTimeStr }}</div>
                                </div>
                                <div class="dl-timeline-side">
                <el-popover
                    placement="bottom"
                    width="200"
                                        width="220"
                    trigger="click"
                    v-model="jumpVisible"
                    @show="initJumpTime">
                    <div style="text-align: center;">
                                        <div style="display: flex; flex-direction: column; gap: 10px;">
                        <el-time-picker 
                            v-model="jumpTime" 
                            size="small" 
                            placeholder="选择时间" 
                            style="width: 100%; margin-bottom: 10px;"
                                                style="width: 100%;"
                            :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>
                                            <button type="button" class="dl-btn" style="width: 100%;" @click="confirmJump">跳转</button>
                    </div>
                    <el-button type="text" slot="reference" icon="el-icon-edit" style="margin-left: 5px; padding: 0;" title="跳转时间"></el-button>
                                        <button slot="reference" type="button" class="dl-btn is-ghost" :disabled="!selectedDeviceSummary">跳转</button>
                </el-popover>
            </div>
             <div style="margin-left: 10px;">
                <el-select v-model="playbackSpeed" style="width: 100px;" size="small" placeholder="倍速">
                                    <el-select v-model="playbackSpeed" size="small" style="width: 92px;" :disabled="!selectedDeviceSummary">
                    <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 class="dl-range-meta">
                                <span>完整范围: {{ timelineRangeText }}</span>
                                <span>已加载片段: {{ loadedSegmentCount }} / {{ timelineMeta.totalFiles || 0 }}</span>
                                <span v-if="selectedLogRow">当前记录: {{ selectedLogRow._summary.title }}</span>
            </div>
        </div>
    </el-dialog>
                    </section>
    <!-- Download Progress Dialog -->
                    <section class="dl-panel dl-detail-panel">
                        <div class="dl-detail-toolbar">
                            <div>
                                <div class="dl-panel-title">{{ detailTab === 'raw' ? '原始数据' : '日志列表' }}</div>
                                <div v-if="detailTab === 'raw'" class="dl-panel-desc">{{ selectedLogRow ? '当前记录的 wcsData JSON 已格式化展示,必要时可切换查看原始文本。' : '先选择一条日志,再查看原始数据。' }}</div>
                            </div>
                            <div class="dl-detail-tabs">
                                <button type="button" class="dl-tab-btn" :class="{ 'is-active': detailTab === 'logs' }" @click="detailTab = 'logs'">日志列表</button>
                                <button type="button" class="dl-tab-btn" :class="{ 'is-active': detailTab === 'raw' }" @click="detailTab = 'raw'" :disabled="!selectedLogRow">原始数据</button>
                            </div>
                        </div>
                        <div class="dl-detail-body">
                            <div v-show="detailTab === 'logs'" class="dl-log-list">
                                <div v-if="timelineLoading || logLoading" class="dl-loading">
                                    <i class="el-icon-loading"></i><span>{{ timelineLoading ? '正在构建设备时间轴...' : '正在加载日志片段...' }}</span>
                                </div>
                                <div v-else-if="!selectedDeviceSummary" class="dl-empty">先返回筛选页选择设备。</div>
                                <div v-else-if="logRows.length === 0" class="dl-empty">{{ logLoadError || '当前设备暂无可展示日志。' }}</div>
                                <div
                                    v-else
                                    v-for="row in logRows"
                                    :key="row._key"
                                    :id="'log-row-' + row._key"
                                    class="dl-log-row"
                                    :class="{ 'is-active': row._key === selectedLogKey }"
                                    @click="handleLogRowClick(row)">
                                    <div class="dl-log-row-head">
                                        <span class="dl-log-time">{{ formatTimestamp(row._ts, true) }}</span>
                                        <span class="dl-log-status" :class="'dl-tone-' + row._summary.tone">{{ row._summary.statusLabel }}</span>
                                    </div>
                                    <div class="dl-log-title">{{ row._summary.title }}</div>
                                    <div class="dl-log-meta-line">{{ buildLogMetaLine(row._summary) }}</div>
                                </div>
                            </div>
                            <div v-show="detailTab === 'raw'" class="dl-raw-content">
                                <template v-if="selectedLogRow">
                                    <div class="dl-raw-toolbar">
                                        <div class="dl-raw-meta">
                                            <span>{{ formatTimestamp(selectedLogRow._ts, true) }}</span>
                                            <span>{{ currentLogTitle }}</span>
                                        </div>
                                        <div class="dl-raw-tabs">
                                            <button type="button" class="dl-tab-btn" :class="{ 'is-active': rawTab === 'wcs' }" @click="rawTab = 'wcs'">WCS JSON</button>
                                            <button type="button" class="dl-tab-btn" :class="{ 'is-active': rawTab === 'origin' }" @click="rawTab = 'origin'">originData</button>
                                        </div>
                                    </div>
                                    <div class="dl-json-box">
                                        <pre class="dl-json-pre">{{ activeRawText }}</pre>
                                    </div>
                                    <div class="dl-json-note">{{ activeRawHint }}</div>
                                </template>
                                <div v-else class="dl-empty">先在日志列表点击一条记录,再查看原始数据。</div>
                            </div>
                        </div>
                    </section>
                </section>
            </div>
        </section>
    </div>
    <el-dialog :title="downloadDialogTitle" :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>
@@ -206,11 +1360,11 @@
<script type="text/javascript" src="../../static/js/common.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/MonitorCardKit.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?v=20260309_i18n_pagefix1" charset="utf-8"></script>
<script src="../../components/MonitorCardKit.js?v=20260318_monitor_v2"></script>
<script src="../../components/WatchCrnCard.js?v=20260318_monitor_v2"></script>
<script src="../../components/WatchRgvCard.js?v=20260318_monitor_v2"></script>
<script src="../../components/WatchDualCrnCard.js?v=20260318_monitor_v2"></script>
<script src="../../components/DevpCard.js?v=20260318_monitor_v2"></script>
<script type="text/javascript" src="../../static/js/deviceLogs/deviceLogs.js?v=20260318_workbench_v9" charset="utf-8"></script>
</body>
</html>