- 后端 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
159 lines
4.3 KiB
TypeScript
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>
|
|
)
|
|
}
|