Showing
1 changed file
with
149 additions
and
63 deletions
| @@ -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() { |
-
Please register or login to post a comment