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