From de8f253fa0784eeedc98aea4ec25df53517218bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E4=B8=80=E4=B8=81?= <1769123563@qq.com> Date: Fri, 21 Nov 2025 00:25:48 +0800 Subject: [PATCH] Improves the Front-End Console Experience --- templates/index.html | 327 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 287 insertions(+), 40 deletions(-) diff --git a/templates/index.html b/templates/index.html index 2544cfb..8af0b6b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -23,6 +23,10 @@ overflow-x: hidden; } + :root { + --console-offset: 52px; /* app切换行 + 状态条的预留高度,运行时同步 */ + } + .container { max-width: 100vw; height: 100vh; /* 固定高度为视口高度 */ @@ -177,7 +181,7 @@ .upload-button input[type="file"] { position: absolute; left: 0; - top: 0; + top: 52px; /* 运行时再用JS同步为app-switcher的真实高度 */ width: 100%; height: 100%; opacity: 0; @@ -238,6 +242,7 @@ background-color: #ffffff; min-height: 0; /* 允许子元素缩小 */ overflow: hidden; /* 防止内容溢出 */ + position: relative; /* 让状态栏悬浮不占用布局空间 */ } /* 应用切换按钮 */ @@ -330,7 +335,11 @@ /* 控制台状态栏 - 显示系统消息而不干扰日志查看 */ .console-status-bar { - height: 0; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 26px; overflow: hidden; background: linear-gradient(180deg, rgba(0, 120, 0, 0.95) 0%, rgba(0, 100, 0, 0.9) 100%); color: #00ff00; @@ -342,13 +351,17 @@ display: flex; justify-content: space-between; align-items: center; - transition: height 0.3s ease, padding 0.3s ease; + pointer-events: none; /* 不抢占滚动 */ + z-index: 2; + opacity: 0; + transform: translateY(-110%); + transition: transform 0.2s ease, opacity 0.2s ease; line-height: 26px; } .console-status-bar.visible { - height: 26px; - padding: 0 15px; + opacity: 1; + transform: translateY(0); } .console-status-bar .status-message { @@ -372,7 +385,7 @@ left: 0; width: 100%; height: 100%; /* 填满整个黑色框 */ - padding: 15px; /* 图层内边距 */ + padding: var(--console-offset, 52px) 15px 15px; /* 顶部预留给状态栏与按钮,不影响滚动计算 */ overflow-y: auto; /* 允许独立滚动 */ overflow-x: hidden; box-sizing: border-box; /* 包含padding在width/height内 */ @@ -1540,8 +1553,13 @@ this.maxVisible = 150; // ↑ from 120(增加可见行数) this.maxLines = 50000; // ↑ from 10000(5倍提升,约5MB/app) this.trimTarget = 40000; // ↑ from 8000(保留更多历史) - this.maxPoolSize = 300; // ↑ from 200(更大DOM池) this.preRenderBuffer = 90; // 新增:上下各预渲染90行,提升滚动流畅度 + this.minPoolSize = 220; // 保底DOM池,避免窗口渲染空白 + this.poolHardLimit = 800; // 池子硬上限,防止撑爆内存 + this.maxPoolSize = Math.max( + this.minPoolSize, + this.maxVisible + this.preRenderBuffer * 2 + 50 + ); // 默认覆盖可视窗口+缓冲区,再留一些余量 this.rafId = null; this.autoScrollEnabled = true; this.resumeDelay = 3000; @@ -1553,6 +1571,10 @@ this.scrollHandler = null; this.beforeSpacer = null; this.afterSpacer = null; + this.watchdogTimer = null; + this.lastRenderAt = 0; + this.idleTimer = null; + this.idleTimeout = 3000; // 用户3秒不滚动后自动吸附 // 【优化吸附逻辑】记录用户滚动位置,用于智能恢复 this.lastUserScrollPosition = 0; // 用户上次滚动的位置 @@ -1605,6 +1627,90 @@ } } + clearIdleTimer() { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + } + + startIdleTimer() { + this.clearIdleTimer(); + this.idleTimer = setTimeout(() => { + this.autoScrollEnabled = true; + this.needsScroll = true; + this.clearResumeTimer(); + this.scrollToBottom(); + }, this.idleTimeout); + } + + // 保障DOM池容量,避免可视窗口比池子大导致空白 + ensurePoolCapacity(neededCount) { + if (!Number.isFinite(neededCount)) return; + + // 根据需求量和基线计算DOM池大小,避免窗口比池子大 + const baseline = Math.min(this.minPoolSize, neededCount + 80); + const targetSize = Math.min( + Math.max(neededCount, baseline, this.pool.length), + this.poolHardLimit + ); + + if (targetSize > this.maxPoolSize) { + this.maxPoolSize = targetSize; + } + + const missing = Math.max(0, targetSize - this.pool.length); + for (let i = 0; i < missing; i++) { + const node = document.createElement('div'); + node.className = 'console-line'; + this.pool.push(node); + } + } + + // DOM意外空白时强制刷新 + forceRenderIfBlank() { + if (!this.container) return false; + if (this.lines.length === 0) return false; + if (this.container.querySelector('.console-line')) return false; + this.lastRenderHash = null; + this.needsScroll = true; + this.scheduleRender(true); + return true; + } + + startWatchdog() { + if (this.watchdogTimer) return; + this.watchdogTimer = setInterval(() => { + if (!this.container) return; + // 黑屏兜底:有数据但没有节点时强制渲染 + const hasLines = this.lines.length > 0; + const hasNodes = !!this.container.querySelector('.console-line'); + if (hasLines && !hasNodes && !this.isRendering) { + this.lastRenderHash = null; + this.needsScroll = true; + this.updateStatusBar('正在恢复显示...', '检测到渲染延迟', false, 1200); + this.scheduleRender(true); + return; + } + + // 渲染长时间未推进时,提醒并触发一次刷新 + const now = performance.now(); + if (hasLines && this.pending.length > 0 && now - this.lastRenderAt > 1500 && !this.isRendering) { + this.lastRenderHash = null; + this.needsScroll = true; + this.updateStatusBar('日志渲染中...', `${this.pending.length} 条待显示`, false, 1200); + this.scheduleRender(true); + } + }, 800); + } + + stopWatchdog() { + if (this.watchdogTimer) { + clearInterval(this.watchdogTimer); + this.watchdogTimer = null; + } + } + attachScroll() { if (!this.scrollElement) return; if (this.scrollHandler) return; // 防止重复绑定 @@ -1673,6 +1779,9 @@ // 显示状态栏 this.statusBar.classList.add('visible'); + if (this.layoutCache) { + this.layoutCache.invalidate(); + } // 清除之前的自动隐藏定时器 if (this.statusBarTimer) { @@ -1686,6 +1795,9 @@ if (this.statusBar) { this.statusBar.classList.remove('visible'); } + if (this.layoutCache) { + this.layoutCache.invalidate(); + } this.statusBarTimer = null; }, duration); } @@ -1699,6 +1811,9 @@ this.statusBarTimer = null; } this.statusBar.classList.remove('visible'); + if (this.layoutCache) { + this.layoutCache.invalidate(); + } } // 添加清理方法 @@ -1707,6 +1822,7 @@ console.log('[性能统计] 最终统计:', this.getPerformanceStats()); // 清理定时器 + this.stopWatchdog(); if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; @@ -1716,6 +1832,7 @@ clearTimeout(this.flushTimer); this.flushTimer = null; } + this.clearIdleTimer(); if (this.statusBarTimer) { clearTimeout(this.statusBarTimer); this.statusBarTimer = null; @@ -1779,7 +1896,7 @@ // 计算距离底部的距离 const distanceFromBottom = scrollHeight - clientHeight - currentScrollTop; - const atBottom = distanceFromBottom <= 50; // 50px阈值 + const atBottom = distanceFromBottom <= 80; // 更宽松的阈值,减少误判 if (atBottom) { // 用户主动滚动到底部,立即启用自动滚动 @@ -1808,6 +1925,7 @@ this.clearResumeTimer(); const delay = this.calculateResumeDelay(distanceFromBottom); this.startResumeTimer(delay); + this.startIdleTimer(); return; } @@ -1815,10 +1933,12 @@ console.log(`[自动吸附] 用户离开底部 ${Math.round(distanceFromBottom)}px,禁用自动滚动`); this.autoScrollEnabled = false; this.clearResumeTimer(); + this.clearIdleTimer(); // 智能计算恢复时间 const delay = this.calculateResumeDelay(distanceFromBottom); this.startResumeTimer(delay); + this.startIdleTimer(); } /** @@ -1847,6 +1967,7 @@ console.log(`[自动吸附] ${delay}ms后恢复自动滚动,吸附到最新日志`); this.autoScrollEnabled = true; this.userScrollDistance = 0; + this.needsScroll = true; // 【优化】恢复时立即吸附到最新 // 如果有新内容已经渲染完成,直接滚动到最新位置 @@ -1891,10 +2012,11 @@ } // 【优化判断】智能阈值: - // - 如果内容少(scrollHeight < 1000px),阈值30px(宽容) - // - 如果内容多,阈值50px(标准) - // 这样能更好地适应不同内容量的情况 - const threshold = scrollHeight < 1000 ? 30 : 50; + // - 超小内容时使用30px + // - 常规50px + // - 当存在状态条/顶部padding时,再增加20px缓冲 + const baseThreshold = scrollHeight < 1000 ? 30 : 50; + const threshold = baseThreshold + 20; const distanceFromBottom = scrollHeight - clientHeight - scrollTop; return distanceFromBottom <= threshold; @@ -1915,9 +2037,42 @@ return this.isNearBottom(); } + // 将最新一行吸附到可视区域内,而不是盲目对齐到底部 + scrollLatestIntoView(margin = 12) { + if (!this.scrollElement || this.scrollLocked) return false; + const lastLine = this.scrollElement.querySelector('.console-line:last-of-type'); + if (!lastLine) return false; + + const container = this.scrollElement; + // 最新一行希望落在视口内并留出少量下边距 + const desiredBottom = container.clientHeight - margin; + const delta = lastLine.offsetTop + lastLine.offsetHeight - desiredBottom; + if (delta > 1 || delta < -1) { + this.scrollLocked = true; + requestAnimationFrame(() => { + container.scrollTop = Math.max(0, container.scrollTop + delta); + if (this.layoutCache) { + this.layoutCache.invalidate(); + } + setTimeout(() => { + this.scrollLocked = false; + }, 80); + }); + } + return true; + } + scrollToBottom() { if (!this.scrollElement) return; + // 优先将最新一行吸附到视口内,保留边距,避免“对齐页面底部”的跳动 + const snapped = this.scrollLatestIntoView(16); + if (snapped) { + this.needsScroll = false; + this.startIdleTimer(); + return; + } + // 【修复黑屏】如果正在渲染,延迟滚动避免冲突 if (this.isRendering) { requestAnimationFrame(() => this.scrollToBottom()); @@ -1989,6 +2144,7 @@ } } this.needsScroll = false; + this.startIdleTimer(); // 解锁滚动(平滑滚动需要更长时间) setTimeout(() => { @@ -2016,9 +2172,12 @@ this.isActive = active; if (active) { + this.startWatchdog(); + // 【新增逻辑】切换引擎时,重置为自动吸附状态 // 默认用户鼠标未滑动3秒以上 this.autoScrollEnabled = true; + this.needsScroll = true; this.clearResumeTimer(); // 【修复】窗口激活时,清除渲染哈希,确保强制渲染 @@ -2112,6 +2271,17 @@ }); } } + + // 保证切换后立即同步最新内容 + this.scheduleRender(true); + this.startIdleTimer(); + + if (this.lines.length > 0) { + requestAnimationFrame(() => this.scrollToBottom()); + } + } else { + this.stopWatchdog(); + this.clearIdleTimer(); } } @@ -2179,12 +2349,25 @@ } append(text, className = 'console-line') { + const nearBottom = this.isNearBottom(); + // 在添加内容前检查是否在底部,如果是则标记需要滚动 - if (this.autoScrollEnabled && this.isNearBottom()) { + if (this.autoScrollEnabled && nearBottom) { this.needsScroll = true; + } else if (!this.autoScrollEnabled && nearBottom) { + // 用户并未真正离开底部,自动恢复吸附 + this.autoScrollEnabled = true; + this.needsScroll = true; + this.clearResumeTimer(); } this.pending.push({ text, className }); + this.pendingHighWaterMark = Math.max(this.pendingHighWaterMark, this.pending.length); + + // 队列堆积时给出进度提示(仅当前活动窗口) + if (this.isActive && this.pending.length > 150) { + this.updateStatusBar('日志渲染中...', `${this.pending.length} 条排队`, false, 1200); + } // 【修复黑屏】统一批处理逻辑 - 所有窗口都实时渲染 // 清除之前的定时器 @@ -2206,6 +2389,9 @@ }, this.batchDelay); } + // 如果容器当前是空的,强制渲染一次,避免“黑屏等待” + this.forceRenderIfBlank(); + this.maybeTrim(); } @@ -2332,6 +2518,7 @@ this.beforeSpacer = null; this.afterSpacer = null; } + this.lastRenderAt = performance.now(); return; } @@ -2349,6 +2536,7 @@ } this.lastRenderHash = contentHash; this.lastRenderLineCount = total; + this.lastRenderAt = performance.now(); // 【优化】计算可见区域 const lh = this.lineHeight; @@ -2374,14 +2562,33 @@ const bufferStart = Math.max(0, rawStart - this.preRenderBuffer); const bufferEnd = Math.min(total, rawStart + visible + this.preRenderBuffer); - const start = bufferStart; - const end = bufferEnd; + let start = bufferStart; + let end = bufferEnd; + let needed = Math.max(0, end - start); + + // 【修复白屏】先保证DOM池容量足够覆盖当前窗口 + this.ensurePoolCapacity(needed); + + // 如果窗口仍超过池容量,收缩窗口贴近最新日志,防止中间缺口 + if (needed > this.pool.length) { + const windowSize = this.pool.length || this.minPoolSize; + end = Math.min(total, Math.max(end, windowSize)); + start = Math.max(0, end - windowSize); + needed = Math.max(0, end - start); + } + + if (needed === 0 && total > 0) { + // 极端情况下兜底展示最新日志,避免空白 + end = total; + start = Math.max(0, end - Math.min(this.maxVisible, total)); + needed = end - start; + this.ensurePoolCapacity(needed); + } + const beforeHeight = start * lh; const afterHeight = (total - end) * lh; - const needed = Math.max(0, end - start); - - // 【优化】限制DOM节点池大小 + // 【优化】限制DOM节点池大小(在更新池容量后执行) if (this.pool.length > this.maxPoolSize) { const excess = this.pool.length - this.maxPoolSize; this.pool.splice(this.maxPoolSize, excess).forEach(node => { @@ -2391,16 +2598,8 @@ }); } - // 【优化】批量创建DOM节点,减少DOM操作 - const nodesToCreate = needed - this.pool.length; - if (nodesToCreate > 0 && this.pool.length < this.maxPoolSize) { - const fragment = document.createDocumentFragment(); - for (let i = 0; i < Math.min(nodesToCreate, this.maxPoolSize - this.pool.length); i++) { - const node = document.createElement('div'); - node.className = 'console-line'; - this.pool.push(node); - } - } + // 再次兜底,确保池容量与窗口一致 + this.ensurePoolCapacity(needed); // 【优化】复用或创建占位符 if (!this.beforeSpacer) { @@ -2479,6 +2678,8 @@ this.scrollToBottom(); }); } + + this.lastRenderAt = performance.now(); }); } else { // 【优化】增量更新:智能diff算法,只更新必要的节点 @@ -2579,6 +2780,8 @@ this.scrollToBottom(); }); } + + this.lastRenderAt = performance.now(); } /** @@ -2607,6 +2810,7 @@ const batchSize = 200; // 每批渲染200行 let currentBatch = 0; const totalBatches = Math.ceil(total / batchSize); + this.lastRenderAt = performance.now(); // 显示渲染进度 const showProgress = () => { @@ -2654,6 +2858,7 @@ // 隐藏状态栏 this.hideStatusBar(); + this.lastRenderAt = performance.now(); // 【优化吸附】渲染完成后的智能滚动 // 1. 自动滚动已启用 -> 直接吸附 @@ -2783,6 +2988,7 @@ // 初始化 document.addEventListener('DOMContentLoaded', function() { initializeConsoleLayers(); + syncStatusBarPosition(); initializeSocket(); initializeEventListeners(); ensureSystemReadyOnLoad(); @@ -2829,6 +3035,9 @@ // 连接探测定时器(保持运行) startConnectionProbe(); + + // 窗口尺寸变化时同步状态栏位置 + window.addEventListener('resize', syncStatusBarPosition); }); // Socket.IO连接 @@ -3628,6 +3837,20 @@ return document.getElementById('consoleOutput'); } + // 同步状态栏位置,避免覆盖应用切换按钮 + function syncStatusBarPosition() { + const bar = document.getElementById('consoleStatusBar'); + const switcher = document.querySelector('.app-switcher'); + if (!bar || !switcher) return; + + const offset = switcher.offsetHeight || 0; + const barHeight = bar.offsetHeight || 26; + const totalOffset = offset + barHeight + 6; // 额外预留6px缓冲 + + bar.style.top = `${offset}px`; + document.documentElement.style.setProperty('--console-offset', `${totalOffset}px`); + } + function initializeConsoleLayers() { const container = getConsoleContainer(); if (!container) return; @@ -3646,11 +3869,7 @@ container.appendChild(layer); consoleLayers[app] = layer; logRenderers[app] = new LogVirtualList(layer); - - // 【图层优化】标记活动窗口 - if (app === currentApp) { - logRenderers[app].isActive = true; - } + logRenderers[app].setActive(app === currentApp); // 【FIX Bug #3】初始提示立即渲染,避免黑屏 logRenderers[app].clear(`[系统] ${appNames[app] || app} 日志就绪`); @@ -3680,11 +3899,7 @@ container.appendChild(layer); consoleLayers[app] = layer; logRenderers[app] = new LogVirtualList(layer); - - // 【图层优化】标记活动窗口 - if (app === currentApp) { - logRenderers[app].isActive = true; - } + logRenderers[app].setActive(app === currentApp); return layer; } @@ -3718,6 +3933,8 @@ const renderer = logRenderers[app]; if (renderer) { renderer.setActive(true); // 会在内部异步渲染待处理内容 + renderer.needsScroll = true; + renderer.scheduleRender(true); // 确保滚动到底部 if (renderer.autoScrollEnabled) { @@ -4485,6 +4702,28 @@ // 创建全局日志管理器实例 const reportLogManager = new ReportLogManager(); + // 新任务时重置报告日志,避免残留历史输出 + function resetReportLogsForNewTask(taskId, reason = '开始新的报告任务,日志已重置') { + if (!taskId) return; + if (reportTaskId === taskId) return; // 已是同一任务,无需重复清空 + + // 停止当前流与轮询,防止旧日志混入 + safeCloseReportStream(); + reportLogManager.stop(); + reportLogManager.reset(); + + // 重置前端计数与缓存 + reportLogLineCount = 0; + lastLineCount['report'] = 0; + + clearConsoleLayer('report', `[系统] ${reason}`); + resetReportStreamOutput('Report Engine 正在启动...'); + + // 重新启动轮询,确保新任务日志即时接入 + reportLogManager.start(); + reportTaskId = taskId; + } + // 【调试】测试日志管理器 window.testReportLogManager = function() { console.log('[测试] ===== 开始测试Report日志管理器 ====='); @@ -5025,12 +5264,14 @@ if (statusData.current_task) { updateTaskProgressStatus(statusData.current_task); if (statusData.current_task.status === 'running') { - reportTaskId = statusData.current_task.task_id; + const taskId = statusData.current_task.task_id; + resetReportLogsForNewTask(taskId, '检测到正在运行的报告任务,日志已重新开始'); + reportTaskId = taskId; reportAutoPreviewLoaded = false; if (window.EventSource) { openReportStream(reportTaskId); } else { - startProgressPolling(reportTaskId); + startProgressPolling(taskId); } } else if (statusData.current_task.status === 'completed') { lastCompletedReportTask = statusData.current_task; @@ -5527,6 +5768,9 @@ // 更新进度显示(保持向后兼容) function updateProgressDisplay(task) { + if (task && task.task_id && task.status === 'running') { + resetReportLogsForNewTask(task.task_id, '检测到新的报告任务,日志已同步重置'); + } updateTaskProgressStatus(task); } @@ -5735,6 +5979,9 @@ const task = payload.task; if (eventType === 'status' && task) { + if (task.status === 'running') { + resetReportLogsForNewTask(task.task_id, '收到流式状态事件,已重置日志'); + } updateTaskProgressStatus(task); reportTaskId = task.status === 'running' ? task.task_id : null; if (task.status === 'completed') {