perf(logs): 日志事件按窗口合并推送,抽屉改为虚拟滚动
- 后端 LogSink 把逐行事件按 16 KiB / 100 ms 合并成一条 log 事件,文件写入保持即时;后台 tick 与析构 flush 保证低输出和收尾都不丢尾部 - 前端新增 LogViewer:固定行高虚拟滚动、跟随底部/回到最新,增量按 rAF 合并,长日志 DOM 节点数恒定 - 日志抽屉改为行缓冲(lib/logLines)+ 可见窗口计算(lib/logWindow) - 详情区改 flex 布局,日志面板吃掉剩余高度,回到最新按钮不再被裁到面板外 - 测试:Rust 单元 52→57,Vitest 13→28,Playwright 4→5(1500 行虚拟滚动) - 文档:README 补充合并推送与虚拟滚动,新增 ADR-0008
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { computeLogWindow, isNearBottom } from '../lib/logWindow'
|
||||
|
||||
/** 每行固定高度(与 CSS 中的 .log-line 一致),虚拟滚动据此换算位置。 */
|
||||
export const LOG_ROW_HEIGHT = 20
|
||||
const LOG_VERTICAL_PADDING = 6
|
||||
const OVERSCAN_ROWS = 24
|
||||
const FOLLOW_THRESHOLD_PX = 24
|
||||
|
||||
interface LogViewerProps {
|
||||
/** 已经切好的日志行;内容会被原地追加,靠 version 触发重渲染。 */
|
||||
lines: readonly string[]
|
||||
/** 每次内容变化递增,用于在"跟随中"时滚到底部。 */
|
||||
version: number
|
||||
emptyLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟滚动的日志面板:只渲染视口附近的行,长日志下 DOM 节点数量恒定。
|
||||
*
|
||||
* 行高固定、不自动换行(长行横向滚动),这样滚动位置可以直接由行号换算,
|
||||
* 不需要逐行测量高度。用户往上翻时停止跟随新日志,并给出"回到最新"按钮。
|
||||
*/
|
||||
export function LogViewer({
|
||||
lines,
|
||||
version,
|
||||
emptyLabel = '等待日志输出...',
|
||||
}: LogViewerProps) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const frameRef = useRef(0)
|
||||
const followingRef = useRef(true)
|
||||
const [following, setFollowing] = useState(true)
|
||||
const [metrics, setMetrics] = useState({ scrollTop: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const node = viewportRef.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
const sync = () => {
|
||||
setMetrics((previous) =>
|
||||
previous.height === node.clientHeight
|
||||
? previous
|
||||
: { ...previous, height: node.clientHeight },
|
||||
)
|
||||
}
|
||||
sync()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (frameRef.current) {
|
||||
return
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = 0
|
||||
const node = viewportRef.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
const atBottom = isNearBottom(
|
||||
node.scrollTop,
|
||||
node.clientHeight,
|
||||
node.scrollHeight,
|
||||
FOLLOW_THRESHOLD_PX,
|
||||
)
|
||||
followingRef.current = atBottom
|
||||
setFollowing(atBottom)
|
||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 有新内容时贴住底部;同步更新滚动位置,避免窗口还停在旧位置闪一下。
|
||||
useLayoutEffect(() => {
|
||||
if (!followingRef.current) {
|
||||
return
|
||||
}
|
||||
const node = viewportRef.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
node.scrollTop = node.scrollHeight
|
||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||
}, [version])
|
||||
|
||||
const jumpToLatest = useCallback(() => {
|
||||
const node = viewportRef.current
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
node.scrollTop = node.scrollHeight
|
||||
followingRef.current = true
|
||||
setFollowing(true)
|
||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||
}, [])
|
||||
|
||||
const { start, end } = computeLogWindow(
|
||||
lines.length,
|
||||
metrics.scrollTop,
|
||||
metrics.height,
|
||||
LOG_ROW_HEIGHT,
|
||||
OVERSCAN_ROWS,
|
||||
)
|
||||
const canvasHeight =
|
||||
lines.length * LOG_ROW_HEIGHT + LOG_VERTICAL_PADDING * 2
|
||||
|
||||
return (
|
||||
<div className="log-viewer-shell">
|
||||
<div
|
||||
className="log-viewer"
|
||||
ref={viewportRef}
|
||||
onScroll={handleScroll}
|
||||
role="log"
|
||||
aria-label="运行日志"
|
||||
tabIndex={0}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<p className="log-empty">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="log-canvas" style={{ height: canvasHeight }}>
|
||||
<div
|
||||
className="log-window"
|
||||
style={{ transform: `translateY(${start * LOG_ROW_HEIGHT}px)` }}
|
||||
>
|
||||
{lines.slice(start, end).map((line, index) => (
|
||||
<div className="log-line" key={start + index}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lines.length > 0 && !following ? (
|
||||
<button
|
||||
type="button"
|
||||
className="log-jump-latest"
|
||||
onClick={jumpToLatest}
|
||||
>
|
||||
回到最新 ↓
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user