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 }, deviceList: [], loading: false, // Enums deviceEnums: {}, // Visualization State visualizationVisible: false, visDeviceType: '', visDeviceNo: '', logs: [], isPlaying: false, playbackSpeed: 1, sliderValue: 0, startTime: 0, endTime: 0, timer: null, currentTime: 0, lastTick: 0, // Jump Time jumpVisible: false, jumpTime: null, seekTargetTime: 0, // Target time we are trying to reach via loading seekingOffset: false, needToSeekOffset: false, // Download State downloadDialogVisible: false, buildProgress: 0, receiveProgress: 0, downloadTimer: null }, computed: { filteredDeviceList() { // Currently just returns the full list loaded for the day return this.deviceList; }, visualizationTitle() { return `日志可视化 - ${this.visDeviceType} ${this.visDeviceNo} (${this.searchForm.day})`; }, maxSliderValue() { return Math.max(0, this.endTime - this.startTime); }, currentTimeStr() { if (!this.currentTime) return ''; var d = new Date(this.currentTime); var Y = d.getFullYear() + '-'; var M = (d.getMonth() + 1 < 10 ? '0' + (d.getMonth() + 1) : d.getMonth() + 1) + '-'; var D = (d.getDate() < 10 ? '0' + d.getDate() : d.getDate()) + ' '; var h = d.getHours().toString().padStart(2, '0'); var m = d.getMinutes().toString().padStart(2, '0'); var s = d.getSeconds().toString().padStart(2, '0'); var ms = d.getMilliseconds().toString().padStart(3, '0'); return Y + M + D + h + ':' + m + ':' + s + '.' + ms; }, canDownload() { return this.searchForm.day && this.searchForm.type && this.searchForm.deviceNo; } }, created() { this.loadDeviceEnums(); this.loadDateTree(); }, methods: { // --- Initialization --- loadDeviceEnums() { let that = this; $.ajax({ url: baseUrl + "/deviceLog/enums/auth", headers: {'token': localStorage.getItem('token')}, method: 'GET', success: function (res) { if (res.code === 200) { that.deviceEnums = res.data || {}; } } }); }, // --- Date Tree --- loadDateTree() { let that = this; $.ajax({ url: baseUrl + "/deviceLog/dates/auth", headers: {'token': localStorage.getItem('token')}, method: 'GET', success: function (res) { if (res.code === 200) { that.dateTreeData = that.buildMonthTree(res.data); // Auto-expand current year/month if needed, or just root if (that.dateTreeData.length > 0) { that.defaultExpandedKeys = [that.dateTreeData[0].id]; } } else if (res.code === 403) { top.location.href = baseUrl + "/"; } else { that.$message.error(res.msg || '加载日期失败'); } } }); }, buildMonthTree(data) { var monthMap = {}; (data || []).forEach(function (y) { (y.children || []).forEach(function (m) { var month = m.title; var arr = monthMap[month] || (monthMap[month] = []); (m.children || []).forEach(function (d) { arr.push({ title: d.title + '日', id: d.id, day: d.id }); }); }); }); var result = []; Object.keys(monthMap).sort().reverse().forEach(function (month) { result.push({ title: month + '月', id: month, children: monthMap[month] }); }); return result; }, handleNodeClick(data) { if (data.day && data.day.length === 8) { this.searchForm.day = data.day; this.loadDevices(data.day); } }, // --- Device List --- loadDevices(day) { this.loading = true; this.deviceList = []; let that = this; $.ajax({ url: baseUrl + "/deviceLog/day/" + day + "/devices/auth", headers: {'token': localStorage.getItem('token')}, method: 'GET', success: function (res) { that.loading = false; if (res.code === 200) { that.deviceList = res.data || []; } else if (res.code === 403) { top.location.href = baseUrl + "/"; } else { that.$message.error(res.msg || '加载设备失败'); } }, error: function() { that.loading = false; that.$message.error('请求失败'); } }); }, // --- Download --- handleBatchDownload() { this.doDownload(this.searchForm.day, this.searchForm.type, this.searchForm.deviceNo); }, downloadLog(deviceNo, type) { this.doDownload(this.searchForm.day, type, deviceNo); }, doDownload(day, type, deviceNo) { if (!day) return this.$message.warning('请先选择日期'); if (!type) return this.$message.warning('请选择设备类型'); if (!deviceNo) return this.$message.warning('请输入设备编号'); let offset = this.searchForm.offset || 0; let limit = this.searchForm.limit || 200; let that = this; $.ajax({ url: baseUrl + "/deviceLog/download/init/auth", headers: {'token': localStorage.getItem('token')}, method: 'POST', data: JSON.stringify({ day: day, type: type, deviceNo: deviceNo, offset: offset, limit: limit }), dataType:'json', contentType:'application/json;charset=UTF-8', success: function (res) { if (res.code !== 200) { that.$message.error(res.msg || '初始化失败'); return; } var 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; } }; return xhr; }, success: function (data, status, xhr) { var disposition = xhr.getResponseHeader('Content-Disposition') || ''; var filename = type + '_' + deviceNo + '_' + day + '.zip'; var match = /filename=(.+)/.exec(disposition); if (match && match[1]) { filename = decodeURIComponent(match[1]); } that.buildProgress = 100; that.receiveProgress = 100; var blob = new Blob([data], {type: 'application/zip'}); var link = document.createElement('a'); var url = window.URL.createObjectURL(blob); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); clearInterval(that.downloadTimer); setTimeout(() => { that.downloadDialogVisible = false; }, 1000); }, error: function () { clearInterval(that.downloadTimer); that.downloadDialogVisible = false; that.$message.error('下载失败或未找到日志'); } }); }, // --- Visualization --- visualizeLog(deviceNo, type) { this.visDeviceType = type; this.visDeviceNo = deviceNo; this.visOffset = this.searchForm.offset || 0; // Optimization: Load fewer files per request to speed up response // searchForm.limit might be large (for download), so we force a small batch for visualization this.visLimit = 2; this.logs = []; this.hasMoreLogs = true; this.loadingLogs = false; this.startTime = 0; this.endTime = 0; this.currentTime = 0; this.sliderValue = 0; this.isPlaying = false; this.playbackSpeed = 1; this.visualizationVisible = true; this.loadMoreLogs(); }, loadMoreLogs() { if (this.loadingLogs || !this.hasMoreLogs) return; this.loadingLogs = true; // Use Vue loading service if available, or element UI loading let loadingInstance = null; // Show loading if explicitly seeking (jumping far ahead) or normal load if (this.seekTargetTime > 0) { if (this.$loading) { loadingInstance = this.$loading({ target: '.vis-container', lock: true, text: '正在跳转至目标时间 (加载中)...', spinner: 'el-icon-loading', background: 'rgba(255, 255, 255, 0.7)' }); } } else if (this.$loading && !this.isPlaying) { loadingInstance = this.$loading({ target: '.vis-container', lock: true, text: '加载数据中...', spinner: 'el-icon-loading', background: 'rgba(255, 255, 255, 0.7)' }); } let that = this; // If seeking and we have no idea where the target time is in terms of files, // we should ask the server for the correct offset first! if (this.seekTargetTime > 0 && this.visOffset === (this.searchForm.offset || 0)) { // First time seeking or reset? No, this condition is tricky. // Actually, if we are seeking, we can call the new /seek endpoint first. // BUT, loadMoreLogs is recursive for seek. We need to be careful. // Let's modify logic: // If seekTargetTime is set, and we suspect it's far away (e.g. not in next batch), // we should use the seek endpoint. // For simplicity, let's ALWAYS try seek endpoint if seeking far ahead? // Or just if we are seeking. // However, loadMoreLogs is currently designed to just load NEXT batch. // We should probably intercept the flow here. } // NEW LOGIC: If seeking, try to find offset first if (this.seekTargetTime > 0 && this.needToSeekOffset && !this.seekingOffset) { this.seekingOffset = true; $.ajax({ url: baseUrl + "/deviceLog/day/" + this.searchForm.day + "/seek/auth", headers: {'token': localStorage.getItem('token')}, data: { type: this.visDeviceType, deviceNo: this.visDeviceNo, timestamp: this.seekTargetTime }, success: function(res) { if (res.code === 200) { var targetOffset = res.data.offset; // Update offset directly that.visOffset = targetOffset; // Clear logs because we jumped that.logs = []; that.seekingOffset = false; that.needToSeekOffset = false; // Now continue to load logs from this new offset // We set seekTargetTime still > 0 so it will check if we arrived. // But we need to call the actual load now. // We recurse (but we need to reset loadingLogs flag first or it returns) // that.loadingLogs = false; // Do not reset loadingLogs here as we are still "loading" // that.loadMoreLogs(); // Recursive call is risky if not careful // Better: call sequential load directly that.loadMoreLogsSequential(loadingInstance); } else { // Fallback to sequential load if seek fails that.seekingOffset = false; that.needToSeekOffset = false; that.loadMoreLogsSequential(loadingInstance); } }, error: function() { that.seekingOffset = false; that.needToSeekOffset = false; that.loadMoreLogsSequential(loadingInstance); } }); return; } this.loadMoreLogsSequential(loadingInstance); }, loadMoreLogsSequential(loadingInstance) { let that = this; let currentLimit = this.seekTargetTime > 0 ? 10 : this.visLimit; $.ajax({ url: baseUrl + "/deviceLog/day/" + this.searchForm.day + "/preview/auth", headers: {'token': localStorage.getItem('token')}, data: { type: this.visDeviceType, deviceNo: this.visDeviceNo, offset: this.visOffset, limit: currentLimit }, success: function(res) { if (loadingInstance) loadingInstance.close(); that.loadingLogs = false; if (res.code === 200) { var newLogs = res.data || []; if (newLogs.length === 0) { that.hasMoreLogs = false; if (that.seekTargetTime > 0) { that.$message.warning('已到达日志末尾,无法到达目标时间'); that.seekTargetTime = 0; } else { if (that.logs.length === 0) that.$message.warning('没有找到日志数据'); else that.$message.info('数据已全部加载'); } return; } // If we cleared logs (jumped), we need to set start time again maybe? // If logs is empty, it means we jumped or initial load. var isJump = that.logs.length === 0; that.logs = that.logs.concat(newLogs); that.visOffset += currentLimit; if (that.logs.length > 0) { if (isJump) { // If we jumped, we need to ensure we don't break startTime if possible, // OR we update startTime if it was 0. // If we jumped to middle, startTime of the whole day is still 0? // No, startTime usually is the beginning of the visualized session. // If we jump, we might want to keep the "view" consistent? // Actually, if we jump, we effectively discard previous logs. // So the slider range might change? // The user expects slider to represent the WHOLE day? // Currently slider represents [startTime, endTime] of LOADED logs. // If we jump, we might lose the "start". // To support "Whole Day" slider, we need startTime of the FIRST log of the day. // But we don't have that if we jump. // For now, let's just update endTime. // If it's a jump, we might need to adjust startTime if it's the first chunk we have. if (that.startTime === 0) { that.startTime = new Date(that.logs[0].createTime).getTime(); that.currentTime = that.startTime; that.$nextTick(() => { that.updateDeviceState(that.logs[0]); }); } } else { // Normal load (initial or sequential) // If initial load (startTime is 0) if (that.startTime === 0) { that.startTime = new Date(that.logs[0].createTime).getTime(); that.currentTime = that.startTime; that.$nextTick(() => { that.updateDeviceState(that.logs[0]); }); } } // Update end time that.endTime = new Date(that.logs[that.logs.length - 1].createTime).getTime(); // Handle Seek Logic if (that.seekTargetTime > 0) { // If we jumped, we should be close. // Check if target is in current range var lastLogTime = new Date(that.logs[that.logs.length - 1].createTime).getTime(); if (lastLogTime >= that.seekTargetTime) { that.currentTime = that.seekTargetTime; that.sliderValue = that.currentTime - that.startTime; that.syncState(); that.seekTargetTime = 0; that.$message.success('已跳转至目标时间'); } else { // Still not there? // If we used /seek, we should be there or very close. // Maybe the file we found ends before target? // We continue loading. setTimeout(() => { that.loadMoreLogs(); }, 50); } } else if (isJump) { // If not seeking (just loaded via jump?), but we cleared logs... // Wait, we only clear logs if seekTargetTime > 0 in the new logic. // So this else is for normal load. } } } else { that.$message.error(res.msg); that.seekTargetTime = 0; } }, error: function() { if (loadingInstance) loadingInstance.close(); that.loadingLogs = false; that.seekTargetTime = 0; that.$message.error('请求失败'); } }); }, handleVisualizationClose() { this.pause(); this.visualizationVisible = false; }, // --- Playback Logic --- play() { this.isPlaying = true; this.lastTick = Date.now(); this.tick(); }, pause() { this.isPlaying = false; if (this.timer) cancelAnimationFrame(this.timer); }, reset() { this.pause(); this.currentTime = this.startTime; this.sliderValue = 0; if (this.logs.length > 0) { this.updateDeviceState(this.logs[0]); } }, tick() { if (!this.isPlaying) return; var now = Date.now(); var delta = now - this.lastTick; this.lastTick = now; // Auto-load more logs if we are close to the end (prefetch) if (this.hasMoreLogs && !this.loadingLogs) { var idx = this.binarySearch(this.currentTime); // If within last 20 frames if (this.logs.length > 0 && (this.logs.length - 1 - idx) < 20) { this.loadMoreLogs(); } } var nextTime = this.currentTime + delta * this.playbackSpeed; if (nextTime >= this.endTime) { if (this.hasMoreLogs) { // Reached end of buffer, but more data available // Clamp to endTime nextTime = this.endTime; // Ensure loading is triggered if (!this.loadingLogs) { this.loadMoreLogs(); } // Update state but do NOT pause this.currentTime = nextTime; this.sliderValue = this.currentTime - this.startTime; this.syncState(); // Continue loop to check again next frame this.timer = requestAnimationFrame(this.tick); return; } else { // Truly finished nextTime = this.endTime; this.currentTime = nextTime; this.sliderValue = this.currentTime - this.startTime; this.syncState(); this.pause(); return; } } this.currentTime = nextTime; this.sliderValue = this.currentTime - this.startTime; this.syncState(); this.timer = requestAnimationFrame(this.tick); }, sliderChange(val) { this.currentTime = this.startTime + val; this.syncState(); // If dragged near the end, load more if (this.hasMoreLogs && !this.loadingLogs) { var idx = this.binarySearch(this.currentTime); if (this.logs.length > 0 && (this.logs.length - 1 - idx) < 20) { this.loadMoreLogs(); } } }, sliderInput(val) { this.currentTime = this.startTime + val; this.syncState(); // If dragged near the end, load more if (this.hasMoreLogs && !this.loadingLogs) { var idx = this.binarySearch(this.currentTime); if (this.logs.length > 0 && (this.logs.length - 1 - idx) < 20) { this.loadMoreLogs(); } } }, syncState() { var idx = this.binarySearch(this.currentTime); if (idx >= 0) { var targetLog = this.logs[idx]; this.updateDeviceState(targetLog); } }, binarySearch(time) { let l = 0, r = this.logs.length - 1; let ans = -1; while (l <= r) { let mid = Math.floor((l + r) / 2); let logTime = new Date(this.logs[mid].createTime).getTime(); if (logTime <= time) { ans = mid; l = mid + 1; } else { r = mid - 1; } } return ans; }, updateDeviceState(logItem) { if (!logItem || !logItem.wcsData) return; try { var protocol = JSON.parse(logItem.wcsData); var list = []; if (this.visDeviceType === 'Devp' && Array.isArray(protocol)) { list = protocol.map(p => this.transformData(p, this.visDeviceType)); list.sort((a, b) => (a.stationId || 0) - (b.stationId || 0)); } else { var data = this.transformData(protocol, this.visDeviceType); list = [data]; } var res = { code: 200, data: list }; if (this.$refs.card) { if (this.visDeviceType === 'Crn') { this.$refs.card.setCrnList(res); } else if (this.visDeviceType === 'Rgv') { this.$refs.card.setRgvList(res); } else if (this.visDeviceType === 'DualCrn') { this.$refs.card.setDualCrnList(res); } else if (this.visDeviceType === 'Devp') { this.$refs.card.setStationList(res); } } } catch (e) { console.error('Error parsing wcsData', e); } }, transformData(protocol, type) { if (!protocol) return {}; // Enums from API var CrnModeType = this.deviceEnums.CrnModeType || {}; var CrnStatusType = this.deviceEnums.CrnStatusType || {}; var CrnForkPosType = this.deviceEnums.CrnForkPosType || {}; var CrnLiftPosType = this.deviceEnums.CrnLiftPosType || {}; var DualCrnForkPosType = this.deviceEnums.DualCrnForkPosType || {}; var DualCrnLiftPosType = this.deviceEnums.DualCrnLiftPosType || {}; var RgvModeType = this.deviceEnums.RgvModeType || {}; var RgvStatusType = this.deviceEnums.RgvStatusType || {}; if (type === 'Crn') { return { crnNo: protocol.crnNo, workNo: protocol.taskNo || 0, mode: CrnModeType[protocol.mode] || '-', status: CrnStatusType[protocol.status] || '-', loading: protocol.loaded == 1 ? '有物' : '无物', bay: protocol.bay, lev: protocol.level, forkOffset: CrnForkPosType[protocol.forkPos] || '-', liftPos: CrnLiftPosType[protocol.liftPos] || '-', walkPos: (protocol.walkPos == 1) ? '不在定位' : '在定位', xspeed: protocol.xSpeed || 0, yspeed: protocol.ySpeed || 0, zspeed: protocol.zSpeed || 0, xdistance: protocol.xDistance || 0, ydistance: protocol.yDistance || 0, warnCode: protocol.alarm, deviceStatus: (protocol.alarm && protocol.alarm > 0) ? 'ERROR' : ((protocol.taskNo && protocol.taskNo > 0) ? 'WORKING' : (protocol.mode == 3 ? 'AUTO' : 'OFFLINE')) }; } else if (type === 'DualCrn') { var vo = { crnNo: protocol.crnNo, taskNo: protocol.taskNo || 0, taskNoTwo: protocol.taskNoTwo || 0, mode: CrnModeType[protocol.mode] || '-', status: CrnStatusType[protocol.status] || '-', statusTwo: CrnStatusType[protocol.statusTwo] || '-', loading: protocol.loaded == 1 ? '有物' : '无物', loadingTwo: protocol.loadedTwo == 1 ? '有物' : '无物', bay: protocol.bay, lev: protocol.level, forkOffset: DualCrnForkPosType[protocol.forkPos] || '-', forkOffsetTwo: DualCrnForkPosType[protocol.forkPosTwo] || '-', liftPos: DualCrnLiftPosType[protocol.liftPos] || '-', walkPos: protocol.walkPos == 0 ? '在定位' : '不在定位', taskReceive: protocol.taskReceive == 1 ? '接收' : '无任务', taskReceiveTwo: protocol.taskReceiveTwo == 1 ? '接收' : '无任务', xspeed: protocol.xSpeed, yspeed: protocol.ySpeed, zspeed: protocol.zSpeed, xdistance: protocol.xDistance, ydistance: protocol.yDistance, warnCode: protocol.alarm }; if (protocol.alarm && protocol.alarm > 0) vo.deviceStatus = 'ERROR'; else if ((protocol.taskNo && protocol.taskNo > 0) || (protocol.taskNoTwo && protocol.taskNoTwo > 0)) vo.deviceStatus = 'WORKING'; else if (protocol.mode == 3) vo.deviceStatus = 'AUTO'; else vo.deviceStatus = 'OFFLINE'; return vo; } else if (type === 'Rgv') { var vo = { rgvNo: protocol.rgvNo, taskNo: protocol.taskNo, mode: RgvModeType[protocol.mode] || '', status: RgvStatusType[protocol.status] || '', loading: protocol.loaded == 1 ? '有物' : '无物', trackSiteNo: protocol.rgvPos, warnCode: protocol.alarm }; var deviceStatus = ""; if (protocol.mode == 3) deviceStatus = "AUTO"; if (protocol.taskNo && protocol.taskNo > 0) deviceStatus = "WORKING"; if (protocol.alarm && protocol.alarm > 0) deviceStatus = "ERROR"; vo.deviceStatus = deviceStatus; return vo; } else if (type === 'Devp') { return { stationId: protocol.stationId, taskNo: protocol.taskNo, targetStaNo: protocol.targetStaNo, autoing: protocol.autoing, loading: protocol.loading, inEnable: protocol.inEnable, outEnable: protocol.outEnable, emptyMk: protocol.emptyMk, fullPlt: protocol.fullPlt, runBlock: protocol.runBlock, enableIn: protocol.enableIn, palletHeight: protocol.palletHeight, barcode: protocol.barcode, weight: protocol.weight, error: protocol.error, errorMsg: protocol.errorMsg, extend: protocol.extend }; } return protocol; }, formatTooltip(val) { var t = this.startTime + val; var d = new Date(t); var Y = d.getFullYear() + '-'; var M = (d.getMonth() + 1 < 10 ? '0' + (d.getMonth() + 1) : d.getMonth() + 1) + '-'; var D = (d.getDate() < 10 ? '0' + d.getDate() : d.getDate()) + ' '; return Y + M + D + d.toLocaleTimeString() + '.' + d.getMilliseconds(); }, initJumpTime() { if (this.currentTime > 0) { this.jumpTime = new Date(this.currentTime); } else if (this.startTime > 0) { this.jumpTime = new Date(this.startTime); } else { // Try to parse from searchForm.day if (this.searchForm.day && this.searchForm.day.length === 8) { var y = this.searchForm.day.substring(0, 4); var m = this.searchForm.day.substring(4, 6); var d = this.searchForm.day.substring(6, 8); // Default to 00:00:00 of that day this.jumpTime = new Date(y + '/' + m + '/' + d + ' 00:00:00'); } else { this.jumpTime = new Date(); } } }, confirmJump() { if (!this.jumpTime) return; // Construct target timestamp // jumpTime from el-time-picker is a Date object (if not using value-format) // or string/timestamp if using value-format. // We didn't set value-format, so it should be Date object (default in ElementUI 2.x?) // Actually, in default_api:Read above, I saw: // // Default v-model for el-time-picker is Date object. let targetDate = this.jumpTime; if (typeof targetDate === 'string' || typeof targetDate === 'number') { targetDate = new Date(targetDate); } let baseDate = new Date(this.startTime > 0 ? this.startTime : Date.now()); baseDate.setHours(targetDate.getHours()); baseDate.setMinutes(targetDate.getMinutes()); baseDate.setSeconds(targetDate.getSeconds()); // Picker usually 0 ms baseDate.setMilliseconds(0); let targetTs = baseDate.getTime(); if (this.startTime > 0 && targetTs < this.startTime) { targetTs = this.startTime; } // Check if beyond endTime if (this.endTime > 0 && targetTs > this.endTime) { // If we have more logs, we try to go as far as we can (endTime) // and trigger loading if (this.hasMoreLogs) { this.seekTargetTime = targetTs; this.needToSeekOffset = true; // Trigger load immediately if (!this.loadingLogs) { this.loadMoreLogs(); } else { // Already loading, just set the target and let callback handle it } this.jumpVisible = false; return; // Don't update current time yet, wait for load } else { targetTs = this.endTime; this.$message.warning('目标时间超出日志范围,已跳转至结束时间'); } } this.currentTime = targetTs; this.sliderValue = this.currentTime - this.startTime; this.syncState(); this.jumpVisible = false; // Trigger load if needed if (this.hasMoreLogs && !this.loadingLogs) { // Force load check this.loadMoreLogs(); } } } });