Showing
1 changed file
with
80 additions
and
13 deletions
| @@ -1228,22 +1228,66 @@ | @@ -1228,22 +1228,66 @@ | ||
| 1228 | let activeConsoleLayer = currentApp; | 1228 | let activeConsoleLayer = currentApp; |
| 1229 | const logRenderers = {}; | 1229 | const logRenderers = {}; |
| 1230 | 1230 | ||
| 1231 | - // 轻量日志虚拟渲染器:不限制总行数,使用可视窗口渲染 + 节流 | 1231 | + // 轻量日志虚拟渲染器:可视窗口渲染 + 节流 + 包级别截断,降低内存占用 |
| 1232 | class LogVirtualList { | 1232 | class LogVirtualList { |
| 1233 | constructor(container) { | 1233 | constructor(container) { |
| 1234 | this.container = container; | 1234 | this.container = container; |
| 1235 | + this.scrollElement = document.getElementById('consoleOutput') || container; | ||
| 1235 | this.lines = []; | 1236 | this.lines = []; |
| 1236 | this.pending = []; | 1237 | this.pending = []; |
| 1237 | this.pool = []; | 1238 | this.pool = []; |
| 1238 | this.lineHeight = 18; | 1239 | this.lineHeight = 18; |
| 1239 | this.maxVisible = 120; | 1240 | this.maxVisible = 120; |
| 1241 | + this.maxLines = 2000; // 硬性保留的最大行数,超出时裁剪老旧数据 | ||
| 1242 | + this.trimTarget = 1500; // 裁剪后保留的目标行数,避免频繁裁剪 | ||
| 1240 | this.rafId = null; | 1243 | this.rafId = null; |
| 1244 | + this.autoScrollEnabled = true; | ||
| 1245 | + this.resumeDelay = 10000; // 手动滚动后重新自动滚动的延迟 | ||
| 1246 | + this.resumeTimer = null; | ||
| 1241 | this.attachScroll(); | 1247 | this.attachScroll(); |
| 1242 | } | 1248 | } |
| 1243 | 1249 | ||
| 1244 | attachScroll() { | 1250 | attachScroll() { |
| 1245 | - if (!this.container) return; | ||
| 1246 | - this.container.addEventListener('scroll', () => this.scheduleRender()); | 1251 | + if (!this.scrollElement) return; |
| 1252 | + this.scrollElement.addEventListener('scroll', () => { | ||
| 1253 | + this.handleUserScroll(); | ||
| 1254 | + this.scheduleRender(); | ||
| 1255 | + }); | ||
| 1256 | + } | ||
| 1257 | + | ||
| 1258 | + handleUserScroll() { | ||
| 1259 | + if (!this.scrollElement) return; | ||
| 1260 | + const atBottom = this.isNearBottom(); | ||
| 1261 | + if (atBottom) { | ||
| 1262 | + this.autoScrollEnabled = true; | ||
| 1263 | + this.clearResumeTimer(); | ||
| 1264 | + return; | ||
| 1265 | + } | ||
| 1266 | + | ||
| 1267 | + this.autoScrollEnabled = false; | ||
| 1268 | + this.clearResumeTimer(); | ||
| 1269 | + this.resumeTimer = setTimeout(() => { | ||
| 1270 | + this.autoScrollEnabled = true; | ||
| 1271 | + this.scrollToBottom(); | ||
| 1272 | + }, this.resumeDelay); | ||
| 1273 | + } | ||
| 1274 | + | ||
| 1275 | + clearResumeTimer() { | ||
| 1276 | + if (this.resumeTimer) { | ||
| 1277 | + clearTimeout(this.resumeTimer); | ||
| 1278 | + this.resumeTimer = null; | ||
| 1279 | + } | ||
| 1280 | + } | ||
| 1281 | + | ||
| 1282 | + isNearBottom() { | ||
| 1283 | + if (!this.scrollElement) return true; | ||
| 1284 | + const { scrollTop, clientHeight, scrollHeight } = this.scrollElement; | ||
| 1285 | + return (scrollTop + clientHeight) >= (scrollHeight - this.lineHeight * 2); | ||
| 1286 | + } | ||
| 1287 | + | ||
| 1288 | + scrollToBottom() { | ||
| 1289 | + if (!this.scrollElement) return; | ||
| 1290 | + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; | ||
| 1247 | } | 1291 | } |
| 1248 | 1292 | ||
| 1249 | setLineHeight(px) { | 1293 | setLineHeight(px) { |
| @@ -1255,6 +1299,7 @@ | @@ -1255,6 +1299,7 @@ | ||
| 1255 | if (this.pending.length > 200) { | 1299 | if (this.pending.length > 200) { |
| 1256 | this.flush(); | 1300 | this.flush(); |
| 1257 | } | 1301 | } |
| 1302 | + this.maybeTrim(); | ||
| 1258 | this.scheduleRender(); | 1303 | this.scheduleRender(); |
| 1259 | } | 1304 | } |
| 1260 | 1305 | ||
| @@ -1272,6 +1317,21 @@ | @@ -1272,6 +1317,21 @@ | ||
| 1272 | if (!this.pending.length) return; | 1317 | if (!this.pending.length) return; |
| 1273 | this.lines.push(...this.pending); | 1318 | this.lines.push(...this.pending); |
| 1274 | this.pending = []; | 1319 | this.pending = []; |
| 1320 | + this.maybeTrim(); | ||
| 1321 | + } | ||
| 1322 | + | ||
| 1323 | + maybeTrim() { | ||
| 1324 | + // 在 flush 之后调用:控制 lines 总量,减少内存 | ||
| 1325 | + if (this.lines.length <= this.maxLines) return; | ||
| 1326 | + | ||
| 1327 | + const toDrop = this.lines.length - this.trimTarget; | ||
| 1328 | + if (toDrop > 0) { | ||
| 1329 | + 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 | + } | ||
| 1334 | + } | ||
| 1275 | } | 1335 | } |
| 1276 | 1336 | ||
| 1277 | scheduleRender(force = false) { | 1337 | scheduleRender(force = false) { |
| @@ -1292,25 +1352,34 @@ | @@ -1292,25 +1352,34 @@ | ||
| 1292 | } | 1352 | } |
| 1293 | 1353 | ||
| 1294 | const lh = this.lineHeight; | 1354 | const lh = this.lineHeight; |
| 1295 | - const viewport = this.container.clientHeight || 1; | 1355 | + const viewport = (this.scrollElement && this.scrollElement.clientHeight) || 1; |
| 1296 | const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible); | 1356 | const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible); |
| 1297 | 1357 | ||
| 1298 | - const start = Math.max(0, Math.floor(this.container.scrollTop / lh) - Math.floor(visible / 2)); | 1358 | + const scrollTop = (this.scrollElement && this.scrollElement.scrollTop) || 0; |
| 1359 | + const halfVisible = Math.floor(visible / 2); | ||
| 1360 | + const rawStart = Math.floor(scrollTop / lh) - halfVisible; | ||
| 1361 | + const start = Math.max(0, Math.min(total, rawStart)); | ||
| 1299 | const end = Math.min(total, start + visible); | 1362 | const end = Math.min(total, start + visible); |
| 1300 | const beforeHeight = start * lh; | 1363 | const beforeHeight = start * lh; |
| 1301 | const afterHeight = (total - end) * lh; | 1364 | const afterHeight = (total - end) * lh; |
| 1302 | 1365 | ||
| 1303 | - const needed = end - start; | 1366 | + const needed = Math.max(0, end - start); |
| 1304 | while (this.pool.length < needed) { | 1367 | while (this.pool.length < needed) { |
| 1305 | const node = document.createElement('div'); | 1368 | const node = document.createElement('div'); |
| 1306 | node.className = 'console-line'; | 1369 | node.className = 'console-line'; |
| 1307 | this.pool.push(node); | 1370 | this.pool.push(node); |
| 1308 | } | 1371 | } |
| 1309 | 1372 | ||
| 1373 | + // 截断池中过期结点,减少 DOM 引用 | ||
| 1374 | + if (needed && this.pool.length > needed * 2) { | ||
| 1375 | + this.pool.length = needed * 2; | ||
| 1376 | + } | ||
| 1377 | + | ||
| 1310 | const fragment = document.createDocumentFragment(); | 1378 | const fragment = document.createDocumentFragment(); |
| 1311 | for (let idx = start; idx < end; idx++) { | 1379 | for (let idx = start; idx < end; idx++) { |
| 1312 | const line = this.lines[idx]; | 1380 | const line = this.lines[idx]; |
| 1313 | const node = this.pool[idx - start]; | 1381 | const node = this.pool[idx - start]; |
| 1382 | + if (!node) continue; // 防御性避免越界 | ||
| 1314 | node.className = line.className || 'console-line'; | 1383 | node.className = line.className || 'console-line'; |
| 1315 | node.textContent = line.text; | 1384 | node.textContent = line.text; |
| 1316 | fragment.appendChild(node); | 1385 | fragment.appendChild(node); |
| @@ -1325,9 +1394,9 @@ | @@ -1325,9 +1394,9 @@ | ||
| 1325 | this.container.appendChild(fragment); | 1394 | this.container.appendChild(fragment); |
| 1326 | this.container.appendChild(afterSpacer); | 1395 | this.container.appendChild(afterSpacer); |
| 1327 | 1396 | ||
| 1328 | - const shouldStick = (this.container.scrollTop + this.container.clientHeight) >= (this.container.scrollHeight - lh * 2); | 1397 | + const shouldStick = this.autoScrollEnabled || this.isNearBottom(); |
| 1329 | if (shouldStick) { | 1398 | if (shouldStick) { |
| 1330 | - this.container.scrollTop = this.container.scrollHeight; | 1399 | + this.scrollToBottom(); |
| 1331 | } | 1400 | } |
| 1332 | } | 1401 | } |
| 1333 | } | 1402 | } |
| @@ -2210,14 +2279,12 @@ | @@ -2210,14 +2279,12 @@ | ||
| 2210 | layer.style.display = 'none'; | 2279 | layer.style.display = 'none'; |
| 2211 | } | 2280 | } |
| 2212 | 2281 | ||
| 2213 | - const placeholder = document.createElement('div'); | ||
| 2214 | - placeholder.className = 'console-line'; | ||
| 2215 | - placeholder.textContent = `[系统] ${appNames[app] || app} 日志就绪`; | ||
| 2216 | - layer.appendChild(placeholder); | ||
| 2217 | logRenderers[app] = new LogVirtualList(layer); | 2282 | logRenderers[app] = new LogVirtualList(layer); |
| 2218 | - | ||
| 2219 | container.appendChild(layer); | 2283 | container.appendChild(layer); |
| 2220 | consoleLayers[app] = layer; | 2284 | consoleLayers[app] = layer; |
| 2285 | + | ||
| 2286 | + // 初始提示仅在渲染器内部渲染,不保留在 DOM | ||
| 2287 | + logRenderers[app].clear(`[系统] ${appNames[app] || app} 日志就绪`); | ||
| 2221 | }); | 2288 | }); |
| 2222 | 2289 | ||
| 2223 | container.scrollTop = container.scrollHeight; | 2290 | container.scrollTop = container.scrollHeight; |
-
Please register or login to post a comment