马一丁

Fix the Front-End Console Display Logic

@@ -1242,21 +1242,30 @@ @@ -1242,21 +1242,30 @@
1242 this.trimTarget = 1500; // 裁剪后保留的目标行数,避免频繁裁剪 1242 this.trimTarget = 1500; // 裁剪后保留的目标行数,避免频繁裁剪
1243 this.rafId = null; 1243 this.rafId = null;
1244 this.autoScrollEnabled = true; 1244 this.autoScrollEnabled = true;
1245 - this.resumeDelay = 10000; // 手动滚动后重新自动滚动的延迟 1245 + this.resumeDelay = 3000; // 手动滚动后重新自动滚动的延迟(降低到3秒)
1246 this.resumeTimer = null; 1246 this.resumeTimer = null;
  1247 + this.lastRenderHash = null; // 用于检测内容是否真正变化
  1248 + this.scrollLocked = false; // 防止滚动冲突的锁
  1249 + this.needsScroll = false; // 标记是否需要滚动
  1250 + this.lastScrollTime = 0; // 上次滚动时间,用于节流
  1251 + this.scrollThrottle = 100; // 滚动节流时间(毫秒)
1247 this.attachScroll(); 1252 this.attachScroll();
1248 } 1253 }
1249 1254
1250 attachScroll() { 1255 attachScroll() {
1251 if (!this.scrollElement) return; 1256 if (!this.scrollElement) return;
  1257 + let scrollTimer = null;
1252 this.scrollElement.addEventListener('scroll', () => { 1258 this.scrollElement.addEventListener('scroll', () => {
1253 - this.handleUserScroll();  
1254 - this.scheduleRender();  
1255 - }); 1259 + // 防抖处理,避免频繁触发
  1260 + if (scrollTimer) clearTimeout(scrollTimer);
  1261 + scrollTimer = setTimeout(() => {
  1262 + this.handleUserScroll();
  1263 + }, 100);
  1264 + }, { passive: true });
1256 } 1265 }
1257 1266
1258 handleUserScroll() { 1267 handleUserScroll() {
1259 - if (!this.scrollElement) return; 1268 + if (!this.scrollElement || this.scrollLocked) return;
1260 const atBottom = this.isNearBottom(); 1269 const atBottom = this.isNearBottom();
1261 if (atBottom) { 1270 if (atBottom) {
1262 this.autoScrollEnabled = true; 1271 this.autoScrollEnabled = true;
@@ -1264,8 +1273,10 @@ @@ -1264,8 +1273,10 @@
1264 return; 1273 return;
1265 } 1274 }
1266 1275
  1276 + // 用户主动向上滚动,禁用自动滚动
1267 this.autoScrollEnabled = false; 1277 this.autoScrollEnabled = false;
1268 this.clearResumeTimer(); 1278 this.clearResumeTimer();
  1279 + // 设置定时器,在用户停止滚动一段时间后自动恢复吸底
1269 this.resumeTimer = setTimeout(() => { 1280 this.resumeTimer = setTimeout(() => {
1270 this.autoScrollEnabled = true; 1281 this.autoScrollEnabled = true;
1271 this.scrollToBottom(); 1282 this.scrollToBottom();
@@ -1282,12 +1293,52 @@ @@ -1282,12 +1293,52 @@
1282 isNearBottom() { 1293 isNearBottom() {
1283 if (!this.scrollElement) return true; 1294 if (!this.scrollElement) return true;
1284 const { scrollTop, clientHeight, scrollHeight } = this.scrollElement; 1295 const { scrollTop, clientHeight, scrollHeight } = this.scrollElement;
1285 - return (scrollTop + clientHeight) >= (scrollHeight - this.lineHeight * 2); 1296 + // 增加阈值到 50px,使吸底判断更宽容
  1297 + return (scrollTop + clientHeight) >= (scrollHeight - 50);
1286 } 1298 }
1287 1299
1288 scrollToBottom() { 1300 scrollToBottom() {
1289 if (!this.scrollElement) return; 1301 if (!this.scrollElement) return;
1290 - this.scrollElement.scrollTop = this.scrollElement.scrollHeight; 1302 +
  1303 + // 节流:如果上次滚动时间距离现在不到 scrollThrottle 毫秒,则跳过
  1304 + const now = Date.now();
  1305 + if (now - this.lastScrollTime < this.scrollThrottle) {
  1306 + return;
  1307 + }
  1308 + this.lastScrollTime = now;
  1309 +
  1310 + // 使用锁防止重入
  1311 + if (this.scrollLocked) return;
  1312 + this.scrollLocked = true;
  1313 +
  1314 + // 使用 requestAnimationFrame 确保在下一帧执行,避免闪烁
  1315 + requestAnimationFrame(() => {
  1316 + if (!this.scrollElement) {
  1317 + this.scrollLocked = false;
  1318 + return;
  1319 + }
  1320 +
  1321 + // 平滑滚动到底部,避免突然跳跃
  1322 + const targetScroll = this.scrollElement.scrollHeight;
  1323 + const currentScroll = this.scrollElement.scrollTop;
  1324 +
  1325 + // 如果已经在底部附近,直接设置,否则平滑滚动
  1326 + if (Math.abs(targetScroll - currentScroll) < 100) {
  1327 + this.scrollElement.scrollTop = targetScroll;
  1328 + } else {
  1329 + // 使用平滑滚动
  1330 + this.scrollElement.scrollTo({
  1331 + top: targetScroll,
  1332 + behavior: 'auto' // 使用 auto 而不是 smooth,避免性能问题
  1333 + });
  1334 + }
  1335 +
  1336 + // 延迟解锁,避免立即触发 scroll 事件导致循环
  1337 + setTimeout(() => {
  1338 + this.scrollLocked = false;
  1339 + this.needsScroll = false; // 滚动完成后重置标志
  1340 + }, 150);
  1341 + });
1291 } 1342 }
1292 1343
1293 setLineHeight(px) { 1344 setLineHeight(px) {
@@ -1295,8 +1346,14 @@ @@ -1295,8 +1346,14 @@
1295 } 1346 }
1296 1347
1297 append(text, className = 'console-line') { 1348 append(text, className = 'console-line') {
  1349 + // 在添加内容前检查是否在底部,如果是则标记需要滚动
  1350 + if (this.autoScrollEnabled && this.isNearBottom()) {
  1351 + this.needsScroll = true;
  1352 + }
  1353 +
1298 this.pending.push({ text, className }); 1354 this.pending.push({ text, className });
1299 - if (this.pending.length > 200) { 1355 + // 降低批处理阈值到 50,更快响应
  1356 + if (this.pending.length > 50) {
1300 this.flush(); 1357 this.flush();
1301 } 1358 }
1302 this.maybeTrim(); 1359 this.maybeTrim();
@@ -1310,6 +1367,8 @@ @@ -1310,6 +1367,8 @@
1310 if (message) { 1367 if (message) {
1311 this.lines.push({ text: message, className: 'console-line' }); 1368 this.lines.push({ text: message, className: 'console-line' });
1312 } 1369 }
  1370 + this.lastRenderHash = null;
  1371 + this.needsScroll = true; // 清空后需要滚动到底部
1313 this.scheduleRender(true); 1372 this.scheduleRender(true);
1314 } 1373 }
1315 1374
@@ -1327,16 +1386,17 @@ @@ -1327,16 +1386,17 @@
1327 const toDrop = this.lines.length - this.trimTarget; 1386 const toDrop = this.lines.length - this.trimTarget;
1328 if (toDrop > 0) { 1387 if (toDrop > 0) {
1329 this.lines.splice(0, toDrop); 1388 this.lines.splice(0, toDrop);
1330 - // 调整滚动位置使得视觉保持在底部附近  
1331 - if (this.scrollElement && !this.autoScrollEnabled) {  
1332 - this.scrollElement.scrollTop = Math.max(0, this.scrollElement.scrollTop - toDrop * this.lineHeight);  
1333 - } 1389 + // 不调整滚动位置,让用户保持当前位置或自动吸底
1334 } 1390 }
1335 } 1391 }
1336 1392
1337 scheduleRender(force = false) { 1393 scheduleRender(force = false) {
1338 if (!this.container) return; 1394 if (!this.container) return;
1339 if (!force && this.rafId) return; 1395 if (!force && this.rafId) return;
  1396 + // 取消之前的请求,使用节流
  1397 + if (this.rafId) {
  1398 + cancelAnimationFrame(this.rafId);
  1399 + }
1340 this.rafId = requestAnimationFrame(() => { 1400 this.rafId = requestAnimationFrame(() => {
1341 this.rafId = null; 1401 this.rafId = null;
1342 this.render(); 1402 this.render();
@@ -1347,10 +1407,23 @@ @@ -1347,10 +1407,23 @@
1347 this.flush(); 1407 this.flush();
1348 const total = this.lines.length; 1408 const total = this.lines.length;
1349 if (!total) { 1409 if (!total) {
1350 - this.container.innerHTML = ''; 1410 + if (this.container.innerHTML !== '') {
  1411 + this.container.innerHTML = '';
  1412 + }
1351 return; 1413 return;
1352 } 1414 }
1353 1415
  1416 + // 计算内容哈希,只在内容真正变化时才更新 DOM
  1417 + const contentHash = `${total}-${this.lines[total - 1].text}`;
  1418 + if (this.lastRenderHash === contentHash) {
  1419 + // 内容没有变化,只需要处理滚动(如果需要的话)
  1420 + if (this.needsScroll && this.autoScrollEnabled) {
  1421 + this.scrollToBottom();
  1422 + }
  1423 + return;
  1424 + }
  1425 + this.lastRenderHash = contentHash;
  1426 +
1354 const lh = this.lineHeight; 1427 const lh = this.lineHeight;
1355 const viewport = (this.scrollElement && this.scrollElement.clientHeight) || 1; 1428 const viewport = (this.scrollElement && this.scrollElement.clientHeight) || 1;
1356 const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible); 1429 const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible);
@@ -1364,39 +1437,60 @@ @@ -1364,39 +1437,60 @@
1364 const afterHeight = (total - end) * lh; 1437 const afterHeight = (total - end) * lh;
1365 1438
1366 const needed = Math.max(0, end - start); 1439 const needed = Math.max(0, end - start);
  1440 +
  1441 + // 复用现有的 DOM 节点池
1367 while (this.pool.length < needed) { 1442 while (this.pool.length < needed) {
1368 const node = document.createElement('div'); 1443 const node = document.createElement('div');
1369 node.className = 'console-line'; 1444 node.className = 'console-line';
1370 this.pool.push(node); 1445 this.pool.push(node);
1371 } 1446 }
1372 1447
1373 - // 截断池中过期结点,减少 DOM 引用  
1374 - if (needed && this.pool.length > needed * 2) {  
1375 - this.pool.length = needed * 2; 1448 + // 不要完全清空容器,而是更新现有节点
  1449 + const existingChildren = Array.from(this.container.children);
  1450 + const fragment = document.createDocumentFragment();
  1451 +
  1452 + // 更新或创建前置占位符
  1453 + let beforeSpacer = existingChildren.find(el => el.dataset.spacer === 'before');
  1454 + if (!beforeSpacer) {
  1455 + beforeSpacer = document.createElement('div');
  1456 + beforeSpacer.dataset.spacer = 'before';
  1457 + }
  1458 + beforeSpacer.style.height = `${beforeHeight}px`;
  1459 +
  1460 + // 更新或创建后置占位符
  1461 + let afterSpacer = existingChildren.find(el => el.dataset.spacer === 'after');
  1462 + if (!afterSpacer) {
  1463 + afterSpacer = document.createElement('div');
  1464 + afterSpacer.dataset.spacer = 'after';
1376 } 1465 }
  1466 + afterSpacer.style.height = `${afterHeight}px`;
1377 1467
1378 - const fragment = document.createDocumentFragment(); 1468 + // 只更新可见区域的节点
1379 for (let idx = start; idx < end; idx++) { 1469 for (let idx = start; idx < end; idx++) {
1380 const line = this.lines[idx]; 1470 const line = this.lines[idx];
1381 const node = this.pool[idx - start]; 1471 const node = this.pool[idx - start];
1382 - if (!node) continue; // 防御性避免越界  
1383 - node.className = line.className || 'console-line';  
1384 - node.textContent = line.text; 1472 + if (!node) continue;
  1473 +
  1474 + // 只在内容或类名变化时才更新节点
  1475 + if (node.textContent !== line.text || node.className !== (line.className || 'console-line')) {
  1476 + node.className = line.className || 'console-line';
  1477 + node.textContent = line.text;
  1478 + }
1385 fragment.appendChild(node); 1479 fragment.appendChild(node);
1386 } 1480 }
1387 1481
  1482 + // 一次性更新 DOM
1388 this.container.innerHTML = ''; 1483 this.container.innerHTML = '';
1389 - const beforeSpacer = document.createElement('div');  
1390 - beforeSpacer.style.height = `${beforeHeight}px`;  
1391 - const afterSpacer = document.createElement('div');  
1392 - afterSpacer.style.height = `${afterHeight}px`;  
1393 this.container.appendChild(beforeSpacer); 1484 this.container.appendChild(beforeSpacer);
1394 this.container.appendChild(fragment); 1485 this.container.appendChild(fragment);
1395 this.container.appendChild(afterSpacer); 1486 this.container.appendChild(afterSpacer);
1396 1487
1397 - const shouldStick = this.autoScrollEnabled || this.isNearBottom();  
1398 - if (shouldStick) {  
1399 - this.scrollToBottom(); 1488 + // 只在有标记且自动滚动启用时才滚动到底部
  1489 + if (this.needsScroll && this.autoScrollEnabled) {
  1490 + // 延迟执行滚动,确保 DOM 已经更新完毕
  1491 + requestAnimationFrame(() => {
  1492 + this.scrollToBottom();
  1493 + });
1400 } 1494 }
1401 } 1495 }
1402 } 1496 }
@@ -1521,16 +1615,20 @@ @@ -1521,16 +1615,20 @@
1521 // 初始化Report Engine锁定状态检查 1615 // 初始化Report Engine锁定状态检查
1522 checkReportLockStatus(); 1616 checkReportLockStatus();
1523 reportLockCheckInterval = setInterval(checkReportLockStatus, 10000); // 每10秒检查一次 1617 reportLockCheckInterval = setInterval(checkReportLockStatus, 10000); // 每10秒检查一次
1524 -  
1525 - // 定期刷新控制台输出  
1526 - setInterval(() => {  
1527 - refreshConsoleOutput();  
1528 - }, 1000);  
1529 -  
1530 - // 定期刷新论坛对话(实时更新) 1618 +
  1619 + // 优化控制台刷新频率:从 1 秒改为 2 秒,减少不必要的 API 调用
1531 setInterval(() => { 1620 setInterval(() => {
1532 - refreshForumMessages(); 1621 + if (appStatus[currentApp] === 'running' || appStatus[currentApp] === 'starting') {
  1622 + refreshConsoleOutput();
  1623 + }
1533 }, 2000); 1624 }, 2000);
  1625 +
  1626 + // 优化论坛对话刷新频率:从 2 秒改为 3 秒
  1627 + setInterval(() => {
  1628 + if (currentApp === 'forum' || appStatus.forum === 'running') {
  1629 + refreshForumMessages();
  1630 + }
  1631 + }, 3000);
1534 1632
1535 // 初始化论坛相关功能 1633 // 初始化论坛相关功能
1536 initializeForum(); 1634 initializeForum();
@@ -2339,15 +2437,9 @@ @@ -2339,15 +2437,9 @@
2339 } 2437 }
2340 2438
2341 function syncConsoleScroll(app) { 2439 function syncConsoleScroll(app) {
2342 - if (app !== currentApp) {  
2343 - return;  
2344 - }  
2345 -  
2346 - const renderer = logRenderers[app];  
2347 - if (renderer && renderer.container) {  
2348 - renderer.container.scrollTop = renderer.container.scrollHeight;  
2349 - consoleLayerScrollPositions[app] = renderer.container.scrollTop;  
2350 - } 2440 + // 这个函数已经不需要了,因为 LogVirtualList 内部已经处理了滚动
  2441 + // 保留函数签名以避免破坏现有调用,但不执行任何操作
  2442 + return;
2351 } 2443 }
2352 2444
2353 function appendConsoleTextLine(app, text, className = 'console-line') { 2445 function appendConsoleTextLine(app, text, className = 'console-line') {
@@ -2358,8 +2450,11 @@ @@ -2358,8 +2450,11 @@
2358 function appendConsoleElement(app, element) { 2450 function appendConsoleElement(app, element) {
2359 const renderer = logRenderers[app] || (logRenderers[app] = new LogVirtualList(getConsoleLayer(app))); 2451 const renderer = logRenderers[app] || (logRenderers[app] = new LogVirtualList(getConsoleLayer(app)));
2360 if (!element || !renderer.container) return; 2452 if (!element || !renderer.container) return;
2361 - renderer.container.appendChild(element);  
2362 - renderer.scheduleRender(true); 2453 +
  2454 + // 将元素转换为文本行,统一使用 LogVirtualList 的渲染逻辑
  2455 + const text = element.textContent || element.innerText || '';
  2456 + const className = element.className || 'console-line';
  2457 + renderer.append(text, className);
2363 } 2458 }
2364 2459
2365 function clearConsoleLayer(app, message = null) { 2460 function clearConsoleLayer(app, message = null) {
@@ -3708,27 +3803,18 @@ @@ -3708,27 +3803,18 @@
3708 return; // 章节内容流式写入不再逐条输出 3803 return; // 章节内容流式写入不再逐条输出
3709 } 3804 }
3710 3805
3711 - const line = document.createElement('div');  
3712 - line.className = `console-line report-stream-line ${level}`;  
3713 -  
3714 - const timestampSpan = document.createElement('span');  
3715 - timestampSpan.className = 'timestamp';  
3716 - timestampSpan.textContent = new Date().toLocaleTimeString('zh-CN');  
3717 - line.appendChild(timestampSpan); 3806 + // 格式化时间戳
  3807 + const timestamp = new Date().toLocaleTimeString('zh-CN');
3718 3808
  3809 + // 构建文本内容而不是 DOM 元素
  3810 + let textContent = `[${timestamp}]`;
3719 if (options.badge) { 3811 if (options.badge) {
3720 - const badge = document.createElement('span');  
3721 - badge.className = 'stream-badge';  
3722 - badge.textContent = options.badge;  
3723 - line.appendChild(badge); 3812 + textContent += ` [${options.badge}]`;
3724 } 3813 }
  3814 + textContent += ` ${message}`;
3725 3815
3726 - const textSpan = document.createElement('span');  
3727 - textSpan.className = 'line-text';  
3728 - textSpan.textContent = message;  
3729 - line.appendChild(textSpan);  
3730 -  
3731 - appendConsoleElement('report', line); 3816 + // 使用统一的文本添加方法,避免直接操作 DOM
  3817 + appendConsoleTextLine('report', textContent, `console-line report-stream-line ${level}`);
3732 } 3818 }
3733 3819
3734 function startStreamHeartbeat() { 3820 function startStreamHeartbeat() {