diff --git a/templates/index.html b/templates/index.html
index 823d7cc..9d28d76 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1242,21 +1242,30 @@
this.trimTarget = 1500; // 裁剪后保留的目标行数,避免频繁裁剪
this.rafId = null;
this.autoScrollEnabled = true;
- this.resumeDelay = 10000; // 手动滚动后重新自动滚动的延迟
+ this.resumeDelay = 3000; // 手动滚动后重新自动滚动的延迟(降低到3秒)
this.resumeTimer = null;
+ this.lastRenderHash = null; // 用于检测内容是否真正变化
+ this.scrollLocked = false; // 防止滚动冲突的锁
+ this.needsScroll = false; // 标记是否需要滚动
+ this.lastScrollTime = 0; // 上次滚动时间,用于节流
+ this.scrollThrottle = 100; // 滚动节流时间(毫秒)
this.attachScroll();
}
attachScroll() {
if (!this.scrollElement) return;
+ let scrollTimer = null;
this.scrollElement.addEventListener('scroll', () => {
- this.handleUserScroll();
- this.scheduleRender();
- });
+ // 防抖处理,避免频繁触发
+ if (scrollTimer) clearTimeout(scrollTimer);
+ scrollTimer = setTimeout(() => {
+ this.handleUserScroll();
+ }, 100);
+ }, { passive: true });
}
handleUserScroll() {
- if (!this.scrollElement) return;
+ if (!this.scrollElement || this.scrollLocked) return;
const atBottom = this.isNearBottom();
if (atBottom) {
this.autoScrollEnabled = true;
@@ -1264,8 +1273,10 @@
return;
}
+ // 用户主动向上滚动,禁用自动滚动
this.autoScrollEnabled = false;
this.clearResumeTimer();
+ // 设置定时器,在用户停止滚动一段时间后自动恢复吸底
this.resumeTimer = setTimeout(() => {
this.autoScrollEnabled = true;
this.scrollToBottom();
@@ -1282,12 +1293,52 @@
isNearBottom() {
if (!this.scrollElement) return true;
const { scrollTop, clientHeight, scrollHeight } = this.scrollElement;
- return (scrollTop + clientHeight) >= (scrollHeight - this.lineHeight * 2);
+ // 增加阈值到 50px,使吸底判断更宽容
+ return (scrollTop + clientHeight) >= (scrollHeight - 50);
}
scrollToBottom() {
if (!this.scrollElement) return;
- this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
+
+ // 节流:如果上次滚动时间距离现在不到 scrollThrottle 毫秒,则跳过
+ const now = Date.now();
+ if (now - this.lastScrollTime < this.scrollThrottle) {
+ return;
+ }
+ this.lastScrollTime = now;
+
+ // 使用锁防止重入
+ if (this.scrollLocked) return;
+ this.scrollLocked = true;
+
+ // 使用 requestAnimationFrame 确保在下一帧执行,避免闪烁
+ requestAnimationFrame(() => {
+ if (!this.scrollElement) {
+ this.scrollLocked = false;
+ return;
+ }
+
+ // 平滑滚动到底部,避免突然跳跃
+ const targetScroll = this.scrollElement.scrollHeight;
+ const currentScroll = this.scrollElement.scrollTop;
+
+ // 如果已经在底部附近,直接设置,否则平滑滚动
+ if (Math.abs(targetScroll - currentScroll) < 100) {
+ this.scrollElement.scrollTop = targetScroll;
+ } else {
+ // 使用平滑滚动
+ this.scrollElement.scrollTo({
+ top: targetScroll,
+ behavior: 'auto' // 使用 auto 而不是 smooth,避免性能问题
+ });
+ }
+
+ // 延迟解锁,避免立即触发 scroll 事件导致循环
+ setTimeout(() => {
+ this.scrollLocked = false;
+ this.needsScroll = false; // 滚动完成后重置标志
+ }, 150);
+ });
}
setLineHeight(px) {
@@ -1295,8 +1346,14 @@
}
append(text, className = 'console-line') {
+ // 在添加内容前检查是否在底部,如果是则标记需要滚动
+ if (this.autoScrollEnabled && this.isNearBottom()) {
+ this.needsScroll = true;
+ }
+
this.pending.push({ text, className });
- if (this.pending.length > 200) {
+ // 降低批处理阈值到 50,更快响应
+ if (this.pending.length > 50) {
this.flush();
}
this.maybeTrim();
@@ -1310,6 +1367,8 @@
if (message) {
this.lines.push({ text: message, className: 'console-line' });
}
+ this.lastRenderHash = null;
+ this.needsScroll = true; // 清空后需要滚动到底部
this.scheduleRender(true);
}
@@ -1327,16 +1386,17 @@
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) {
if (!this.container) return;
if (!force && this.rafId) return;
+ // 取消之前的请求,使用节流
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId);
+ }
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.render();
@@ -1347,10 +1407,23 @@
this.flush();
const total = this.lines.length;
if (!total) {
- this.container.innerHTML = '';
+ if (this.container.innerHTML !== '') {
+ this.container.innerHTML = '';
+ }
return;
}
+ // 计算内容哈希,只在内容真正变化时才更新 DOM
+ const contentHash = `${total}-${this.lines[total - 1].text}`;
+ if (this.lastRenderHash === contentHash) {
+ // 内容没有变化,只需要处理滚动(如果需要的话)
+ if (this.needsScroll && this.autoScrollEnabled) {
+ this.scrollToBottom();
+ }
+ return;
+ }
+ this.lastRenderHash = contentHash;
+
const lh = this.lineHeight;
const viewport = (this.scrollElement && this.scrollElement.clientHeight) || 1;
const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible);
@@ -1364,39 +1437,60 @@
const afterHeight = (total - end) * lh;
const needed = Math.max(0, end - start);
+
+ // 复用现有的 DOM 节点池
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 existingChildren = Array.from(this.container.children);
const fragment = document.createDocumentFragment();
+
+ // 更新或创建前置占位符
+ let beforeSpacer = existingChildren.find(el => el.dataset.spacer === 'before');
+ if (!beforeSpacer) {
+ beforeSpacer = document.createElement('div');
+ beforeSpacer.dataset.spacer = 'before';
+ }
+ beforeSpacer.style.height = `${beforeHeight}px`;
+
+ // 更新或创建后置占位符
+ let afterSpacer = existingChildren.find(el => el.dataset.spacer === 'after');
+ if (!afterSpacer) {
+ afterSpacer = document.createElement('div');
+ afterSpacer.dataset.spacer = 'after';
+ }
+ afterSpacer.style.height = `${afterHeight}px`;
+
+ // 只更新可见区域的节点
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;
+ if (!node) continue;
+
+ // 只在内容或类名变化时才更新节点
+ if (node.textContent !== line.text || node.className !== (line.className || 'console-line')) {
+ node.className = line.className || 'console-line';
+ node.textContent = line.text;
+ }
fragment.appendChild(node);
}
+ // 一次性更新 DOM
this.container.innerHTML = '';
- const beforeSpacer = document.createElement('div');
- beforeSpacer.style.height = `${beforeHeight}px`;
- const afterSpacer = document.createElement('div');
- afterSpacer.style.height = `${afterHeight}px`;
this.container.appendChild(beforeSpacer);
this.container.appendChild(fragment);
this.container.appendChild(afterSpacer);
- const shouldStick = this.autoScrollEnabled || this.isNearBottom();
- if (shouldStick) {
- this.scrollToBottom();
+ // 只在有标记且自动滚动启用时才滚动到底部
+ if (this.needsScroll && this.autoScrollEnabled) {
+ // 延迟执行滚动,确保 DOM 已经更新完毕
+ requestAnimationFrame(() => {
+ this.scrollToBottom();
+ });
}
}
}
@@ -1521,16 +1615,20 @@
// 初始化Report Engine锁定状态检查
checkReportLockStatus();
reportLockCheckInterval = setInterval(checkReportLockStatus, 10000); // 每10秒检查一次
-
- // 定期刷新控制台输出
+
+ // 优化控制台刷新频率:从 1 秒改为 2 秒,减少不必要的 API 调用
setInterval(() => {
- refreshConsoleOutput();
- }, 1000);
-
- // 定期刷新论坛对话(实时更新)
- setInterval(() => {
- refreshForumMessages();
+ if (appStatus[currentApp] === 'running' || appStatus[currentApp] === 'starting') {
+ refreshConsoleOutput();
+ }
}, 2000);
+
+ // 优化论坛对话刷新频率:从 2 秒改为 3 秒
+ setInterval(() => {
+ if (currentApp === 'forum' || appStatus.forum === 'running') {
+ refreshForumMessages();
+ }
+ }, 3000);
// 初始化论坛相关功能
initializeForum();
@@ -2339,15 +2437,9 @@
}
function syncConsoleScroll(app) {
- if (app !== currentApp) {
- return;
- }
-
- const renderer = logRenderers[app];
- if (renderer && renderer.container) {
- renderer.container.scrollTop = renderer.container.scrollHeight;
- consoleLayerScrollPositions[app] = renderer.container.scrollTop;
- }
+ // 这个函数已经不需要了,因为 LogVirtualList 内部已经处理了滚动
+ // 保留函数签名以避免破坏现有调用,但不执行任何操作
+ return;
}
function appendConsoleTextLine(app, text, className = 'console-line') {
@@ -2358,8 +2450,11 @@
function appendConsoleElement(app, element) {
const renderer = logRenderers[app] || (logRenderers[app] = new LogVirtualList(getConsoleLayer(app)));
if (!element || !renderer.container) return;
- renderer.container.appendChild(element);
- renderer.scheduleRender(true);
+
+ // 将元素转换为文本行,统一使用 LogVirtualList 的渲染逻辑
+ const text = element.textContent || element.innerText || '';
+ const className = element.className || 'console-line';
+ renderer.append(text, className);
}
function clearConsoleLayer(app, message = null) {
@@ -3708,27 +3803,18 @@
return; // 章节内容流式写入不再逐条输出
}
- const line = document.createElement('div');
- line.className = `console-line report-stream-line ${level}`;
-
- const timestampSpan = document.createElement('span');
- timestampSpan.className = 'timestamp';
- timestampSpan.textContent = new Date().toLocaleTimeString('zh-CN');
- line.appendChild(timestampSpan);
+ // 格式化时间戳
+ const timestamp = new Date().toLocaleTimeString('zh-CN');
+ // 构建文本内容而不是 DOM 元素
+ let textContent = `[${timestamp}]`;
if (options.badge) {
- const badge = document.createElement('span');
- badge.className = 'stream-badge';
- badge.textContent = options.badge;
- line.appendChild(badge);
+ textContent += ` [${options.badge}]`;
}
+ textContent += ` ${message}`;
- const textSpan = document.createElement('span');
- textSpan.className = 'line-text';
- textSpan.textContent = message;
- line.appendChild(textSpan);
-
- appendConsoleElement('report', line);
+ // 使用统一的文本添加方法,避免直接操作 DOM
+ appendConsoleTextLine('report', textContent, `console-line report-stream-line ${level}`);
}
function startStreamHeartbeat() {