diff --git a/templates/index.html b/templates/index.html index d5ce86a..ab79b85 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1228,22 +1228,66 @@ let activeConsoleLayer = currentApp; const logRenderers = {}; - // 轻量日志虚拟渲染器:不限制总行数,使用可视窗口渲染 + 节流 + // 轻量日志虚拟渲染器:可视窗口渲染 + 节流 + 包级别截断,降低内存占用 class LogVirtualList { constructor(container) { this.container = container; + this.scrollElement = document.getElementById('consoleOutput') || container; this.lines = []; this.pending = []; this.pool = []; this.lineHeight = 18; this.maxVisible = 120; + this.maxLines = 2000; // 硬性保留的最大行数,超出时裁剪老旧数据 + this.trimTarget = 1500; // 裁剪后保留的目标行数,避免频繁裁剪 this.rafId = null; + this.autoScrollEnabled = true; + this.resumeDelay = 10000; // 手动滚动后重新自动滚动的延迟 + this.resumeTimer = null; this.attachScroll(); } attachScroll() { - if (!this.container) return; - this.container.addEventListener('scroll', () => this.scheduleRender()); + if (!this.scrollElement) return; + this.scrollElement.addEventListener('scroll', () => { + this.handleUserScroll(); + this.scheduleRender(); + }); + } + + handleUserScroll() { + if (!this.scrollElement) return; + const atBottom = this.isNearBottom(); + if (atBottom) { + this.autoScrollEnabled = true; + this.clearResumeTimer(); + return; + } + + this.autoScrollEnabled = false; + this.clearResumeTimer(); + this.resumeTimer = setTimeout(() => { + this.autoScrollEnabled = true; + this.scrollToBottom(); + }, this.resumeDelay); + } + + clearResumeTimer() { + if (this.resumeTimer) { + clearTimeout(this.resumeTimer); + this.resumeTimer = null; + } + } + + isNearBottom() { + if (!this.scrollElement) return true; + const { scrollTop, clientHeight, scrollHeight } = this.scrollElement; + return (scrollTop + clientHeight) >= (scrollHeight - this.lineHeight * 2); + } + + scrollToBottom() { + if (!this.scrollElement) return; + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; } setLineHeight(px) { @@ -1255,6 +1299,7 @@ if (this.pending.length > 200) { this.flush(); } + this.maybeTrim(); this.scheduleRender(); } @@ -1272,6 +1317,21 @@ if (!this.pending.length) return; this.lines.push(...this.pending); this.pending = []; + this.maybeTrim(); + } + + maybeTrim() { + // 在 flush 之后调用:控制 lines 总量,减少内存 + if (this.lines.length <= this.maxLines) return; + + const toDrop = this.lines.length - this.trimTarget; + if (toDrop > 0) { + this.lines.splice(0, toDrop); + // 调整滚动位置使得视觉保持在底部附近 + if (this.scrollElement && !this.autoScrollEnabled) { + this.scrollElement.scrollTop = Math.max(0, this.scrollElement.scrollTop - toDrop * this.lineHeight); + } + } } scheduleRender(force = false) { @@ -1292,25 +1352,34 @@ } const lh = this.lineHeight; - const viewport = this.container.clientHeight || 1; + const viewport = (this.scrollElement && this.scrollElement.clientHeight) || 1; const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible); - const start = Math.max(0, Math.floor(this.container.scrollTop / lh) - Math.floor(visible / 2)); + const scrollTop = (this.scrollElement && this.scrollElement.scrollTop) || 0; + const halfVisible = Math.floor(visible / 2); + const rawStart = Math.floor(scrollTop / lh) - halfVisible; + const start = Math.max(0, Math.min(total, rawStart)); const end = Math.min(total, start + visible); const beforeHeight = start * lh; const afterHeight = (total - end) * lh; - const needed = end - start; + const needed = Math.max(0, end - start); while (this.pool.length < needed) { const node = document.createElement('div'); node.className = 'console-line'; this.pool.push(node); } + // 截断池中过期结点,减少 DOM 引用 + if (needed && this.pool.length > needed * 2) { + this.pool.length = needed * 2; + } + const fragment = document.createDocumentFragment(); for (let idx = start; idx < end; idx++) { const line = this.lines[idx]; const node = this.pool[idx - start]; + if (!node) continue; // 防御性避免越界 node.className = line.className || 'console-line'; node.textContent = line.text; fragment.appendChild(node); @@ -1325,9 +1394,9 @@ this.container.appendChild(fragment); this.container.appendChild(afterSpacer); - const shouldStick = (this.container.scrollTop + this.container.clientHeight) >= (this.container.scrollHeight - lh * 2); + const shouldStick = this.autoScrollEnabled || this.isNearBottom(); if (shouldStick) { - this.container.scrollTop = this.container.scrollHeight; + this.scrollToBottom(); } } } @@ -2210,14 +2279,12 @@ layer.style.display = 'none'; } - const placeholder = document.createElement('div'); - placeholder.className = 'console-line'; - placeholder.textContent = `[系统] ${appNames[app] || app} 日志就绪`; - layer.appendChild(placeholder); logRenderers[app] = new LogVirtualList(layer); - container.appendChild(layer); consoleLayers[app] = layer; + + // 初始提示仅在渲染器内部渲染,不保留在 DOM + logRenderers[app].clear(`[系统] ${appNames[app] || app} 日志就绪`); }); container.scrollTop = container.scrollHeight;