diff --git a/frontend/src/components/LogViewer/MonacoLogViewer.tsx b/frontend/src/components/LogViewer/MonacoLogViewer.tsx index df401571..9fa34010 100644 --- a/frontend/src/components/LogViewer/MonacoLogViewer.tsx +++ b/frontend/src/components/LogViewer/MonacoLogViewer.tsx @@ -6,16 +6,24 @@ * - 直接操作Monaco Model,避免React状态更新 * - 批量追加日志,减少更新频率 * - 限制最大行数,防止内存溢出 + * + * 智能滚动: + * - 用户在底部时自动跟随新日志 + * - 用户向上滚动查看时暂停跟随 + * - 显示新日志数量提示,点击可跳转到底部 */ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import Editor, { Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import type { MonacoLogViewerProps, LogViewerAPI } from './types'; +import { ChevronDown } from 'lucide-react'; // 默认最大行数限制 const DEFAULT_MAX_LINES = 10000; // 批量更新间隔(ms) const BATCH_INTERVAL = 50; +// 底部判定阈值(px) +const BOTTOM_THRESHOLD = 150; export const MonacoLogViewer: React.FC = ({ autoScroll = true, @@ -36,13 +44,27 @@ export const MonacoLogViewer: React.FC = ({ // 批量更新缓冲区 const pendingLogsRef = useRef([]); const batchTimerRef = useRef(null); - const isReadyRef = useRef(false); + + // 智能滚动状态 + const [isFollowing, setIsFollowing] = useState(true); + const [newLogsCount, setNewLogsCount] = useState(0); + const isFollowingRef = useRef(true); + const newLogsCountRef = useRef(0); // 更新自动滚动状态 useEffect(() => { autoScrollRef.current = autoScroll; }, [autoScroll]); + // 同步 ref + useEffect(() => { + isFollowingRef.current = isFollowing; + }, [isFollowing]); + + useEffect(() => { + newLogsCountRef.current = newLogsCount; + }, [newLogsCount]); + // 更新wordWrap设置 useEffect(() => { if (editorRef.current) { @@ -67,7 +89,7 @@ export const MonacoLogViewer: React.FC = ({ const scrollTop = editorRef.current.getScrollTop(); const scrollHeight = editorRef.current.getScrollHeight(); const clientHeight = editorRef.current.getLayoutInfo().height; - return scrollHeight - scrollTop - clientHeight < 50; + return scrollHeight - scrollTop - clientHeight < BOTTOM_THRESHOLD; }, []); // 滚动到底部 @@ -80,18 +102,25 @@ export const MonacoLogViewer: React.FC = ({ } }, []); + // 跳转到底部并恢复跟随 + const jumpToBottom = useCallback(() => { + setIsFollowing(true); + setNewLogsCount(0); + scrollToBottom(); + }, [scrollToBottom]); + // 直接追加文本到Monaco Model(高性能) - const appendToModel = useCallback((text: string) => { + const appendToModel = useCallback((text: string, logCount: number) => { if (!editorRef.current) return; const model = editorRef.current.getModel(); if (!model) return; - const shouldScroll = autoScrollRef.current && isNearBottom(); + const shouldScroll = autoScrollRef.current && isFollowingRef.current && isNearBottom(); const lineCount = model.getLineCount(); const lastLineLength = model.getLineLength(lineCount); - // 使用 applyEdits 直接追加文本,避免重新解析整个文档 + // 使用 applyEdits 直接追加文本 const prefix = lineCount === 1 && lastLineLength === 0 ? '' : '\n'; model.applyEdits([{ range: { @@ -106,7 +135,7 @@ export const MonacoLogViewer: React.FC = ({ // 更新行数 lineCountRef.current = model.getLineCount(); - // 检查是否超过最大行数,删除旧日志 + // 检查是否超过最大行数 if (maxLines && lineCountRef.current > maxLines) { const linesToRemove = lineCountRef.current - maxLines; model.applyEdits([{ @@ -121,9 +150,12 @@ export const MonacoLogViewer: React.FC = ({ lineCountRef.current = model.getLineCount(); } - // 自动滚动到底部 + // 自动滚动或累计新日志数 if (shouldScroll) { requestAnimationFrame(scrollToBottom); + } else if (!isFollowingRef.current || !isNearBottom()) { + // 不在底部,累计新日志数量 + setNewLogsCount(prev => prev + logCount); } }, [isNearBottom, scrollToBottom, maxLines]); @@ -132,19 +164,18 @@ export const MonacoLogViewer: React.FC = ({ if (pendingLogsRef.current.length === 0) return; const logs = pendingLogsRef.current; + const count = logs.length; pendingLogsRef.current = []; batchTimerRef.current = null; - // 合并所有待处理日志 const text = logs.join('\n'); - appendToModel(text); + appendToModel(text, count); }, [appendToModel]); - // 添加日志到缓冲区(批量处理) + // 添加日志到缓冲区 const queueLog = useCallback((log: string) => { pendingLogsRef.current.push(log); - // 如果没有定时器,启动批量更新 if (!batchTimerRef.current) { batchTimerRef.current = setTimeout(flushPendingLogs, BATCH_INTERVAL); } @@ -160,19 +191,20 @@ export const MonacoLogViewer: React.FC = ({ lineCountRef.current = 0; } - // 清空缓冲区 pendingLogsRef.current = []; if (batchTimerRef.current) { clearTimeout(batchTimerRef.current); batchTimerRef.current = null; } + + setNewLogsCount(0); + setIsFollowing(true); }, []); // Monaco Editor挂载完成 const handleEditorDidMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { editorRef.current = editor; monacoRef.current = monaco; - isReadyRef.current = true; // 配置编辑器选项 editor.updateOptions({ @@ -187,7 +219,6 @@ export const MonacoLogViewer: React.FC = ({ lineNumbersMinChars: 6, renderLineHighlight: 'none', automaticLayout: true, - // 性能优化选项 renderWhitespace: 'none', renderControlCharacters: false, guides: { indentation: false }, @@ -202,37 +233,41 @@ export const MonacoLogViewer: React.FC = ({ }, }); + // 监听滚动事件,智能切换跟随状态 + editor.onDidScrollChange(() => { + const nearBottom = isNearBottom(); + + if (nearBottom && !isFollowingRef.current) { + // 滚动到底部,恢复跟随 + setIsFollowing(true); + setNewLogsCount(0); + } else if (!nearBottom && isFollowingRef.current) { + // 离开底部,暂停跟随 + setIsFollowing(false); + } + }); + // 暴露API if (onReady) { const api: LogViewerAPI = { - appendLog: (log: string) => { - queueLog(log); - }, - appendLogs: (logs: string[]) => { - logs.forEach(log => queueLog(log)); - }, + appendLog: queueLog, + appendLogs: (logs: string[]) => logs.forEach(queueLog), clear: clearLogs, - scrollToBottom, + scrollToBottom: jumpToBottom, search: (text: string) => { - if (editorRef.current) { - editorRef.current.trigger('', 'actions.find', { searchString: text }); - } + editor.trigger('', 'actions.find', { searchString: text }); }, findNext: () => { - if (editorRef.current) { - editorRef.current.trigger('', 'editor.action.nextMatchFindAction', null); - } + editor.trigger('', 'editor.action.nextMatchFindAction', null); }, findPrevious: () => { - if (editorRef.current) { - editorRef.current.trigger('', 'editor.action.previousMatchFindAction', null); - } + editor.trigger('', 'editor.action.previousMatchFindAction', null); }, getLineCount: () => lineCountRef.current, }; onReady(api); } - }, [wordWrap, onReady, queueLog, clearLogs, scrollToBottom]); + }, [wordWrap, onReady, queueLog, clearLogs, jumpToBottom, isNearBottom]); return (
@@ -270,6 +305,17 @@ export const MonacoLogViewer: React.FC = ({ }} onMount={handleEditorDidMount} /> + + {/* 新日志提示按钮 */} + {newLogsCount > 0 && ( + + )}
); };