Files
task_pilot/frontend/src/components/LogViewer.tsx
T
CNWei 07ca14ea34 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
2026-10-27 22:38:53 +08:00

159 lines
4.3 KiB
TypeScript

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>
)
}