diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index b69cb89..86c5ada 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; @@ -34,6 +34,7 @@ import { type ParsedLogLine, } from './hooks/logTypes'; import { useLogFilters } from './hooks/useLogFilters'; +import { isNearBottom, useLogScroller } from './hooks/useLogScroller'; import { isTraceableRequestPath, useTraceResolver } from './hooks/useTraceResolver'; import styles from './LogsPage.module.scss'; @@ -45,9 +46,7 @@ interface ErrorLogItem { // 初始只渲染最近 100 行,滚动到顶部再逐步加载更多(避免一次性渲染过多导致卡顿) const INITIAL_DISPLAY_LINES = 100; -const LOAD_MORE_LINES = 200; const MAX_BUFFER_LINES = 10000; -const LOAD_MORE_THRESHOLD_PX = 72; const LONG_PRESS_MS = 650; const LONG_PRESS_MOVE_THRESHOLD = 10; @@ -369,9 +368,7 @@ export function LogsPage() { requestLogDownloading }); - const logViewerRef = useRef(null); - const pendingScrollToBottomRef = useRef(false); - const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); + const logScrollerRef = useRef | null>(null); const longPressRef = useRef<{ timer: number | null; startX: number; @@ -386,18 +383,6 @@ export function LogsPage() { const disableControls = connectionStatus !== 'connected'; - const isNearBottom = (node: HTMLDivElement | null) => { - if (!node) return true; - const threshold = 24; - return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold; - }; - - const scrollToBottom = () => { - const node = logViewerRef.current; - if (!node) return; - node.scrollTop = node.scrollHeight; - }; - const loadLogs = async (incremental = false) => { if (connectionStatus !== 'connected') { setLoading(false); @@ -419,7 +404,11 @@ export function LogsPage() { setError(''); try { - pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current); + const scroller = logScrollerRef.current; + const stickToBottom = !incremental || isNearBottom(scroller?.logViewerRef.current ?? null); + if (stickToBottom) { + scroller?.requestScrollToBottom(); + } const params = incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {}; @@ -442,7 +431,7 @@ export function LogsPage() { let visibleFrom = Math.max(prev.visibleFrom - dropCount, 0); // 若用户停留在底部(跟随最新日志),则保持“渲染窗口”大小不变,避免无限增长 - if (pendingScrollToBottomRef.current) { + if (stickToBottom) { visibleFrom = Math.max(buffer.length - prevRenderedCount, 0); } @@ -566,15 +555,6 @@ export function LogsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoRefresh, connectionStatus]); - useEffect(() => { - if (!pendingScrollToBottomRef.current) return; - if (loading) return; - if (!logViewerRef.current) return; - - scrollToBottom(); - pendingScrollToBottomRef.current = false; - }, [loading, logState.buffer, logState.visibleFrom]); - const visibleLines = useMemo( () => logState.buffer.slice(logState.visibleFrom), [logState.buffer, logState.visibleFrom] @@ -645,98 +625,17 @@ export function LogsPage() { const rawVisibleText = useMemo(() => filteredLines.join('\n'), [filteredLines]); - const canLoadMore = !isSearching && logState.visibleFrom > 0; - - const prependVisibleLines = useCallback(() => { - const node = logViewerRef.current; - if (!node) return; - if (pendingPrependScrollRef.current) return; - if (isSearching) return; - - setLogState((prev) => { - if (prev.visibleFrom <= 0) { - return prev; - } - - pendingPrependScrollRef.current = { - scrollHeight: node.scrollHeight, - scrollTop: node.scrollTop, - }; - - return { - ...prev, - visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0), - }; - }); - }, [isSearching]); - - const handleLogScroll = () => { - const node = logViewerRef.current; - if (!node) return; - if (isSearching) return; - if (!canLoadMore) return; - if (pendingPrependScrollRef.current) return; - if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return; - - prependVisibleLines(); - }; - - useLayoutEffect(() => { - const node = logViewerRef.current; - const pending = pendingPrependScrollRef.current; - if (!node || !pending) return; - - const delta = node.scrollHeight - pending.scrollHeight; - node.scrollTop = pending.scrollTop + delta; - pendingPrependScrollRef.current = null; - }, [logState.visibleFrom]); - - const tryAutoLoadMoreUntilScrollable = useCallback(() => { - const node = logViewerRef.current; - if (!node) return; - if (!canLoadMore) return; - if (isSearching) return; - if (pendingPrependScrollRef.current) return; - - const hasVerticalOverflow = node.scrollHeight > node.clientHeight + 1; - if (hasVerticalOverflow) return; - - prependVisibleLines(); - }, [canLoadMore, isSearching, prependVisibleLines]); - - useEffect(() => { - if (loading) return; - if (activeTab !== 'logs') return; - - const raf = window.requestAnimationFrame(() => { - tryAutoLoadMoreUntilScrollable(); - }); - return () => { - window.cancelAnimationFrame(raf); - }; - }, [ - activeTab, + const scroller = useLogScroller({ + logState, + setLogState, loading, - tryAutoLoadMoreUntilScrollable, - filteredLines.length, - showRawLogs, - logState.visibleFrom, - ]); + isSearching, + filteredLineCount: filteredLines.length, + hasStructuredFilters: filters.hasStructuredFilters, + showRawLogs + }); - useEffect(() => { - if (activeTab !== 'logs') return; - - const onResize = () => { - window.requestAnimationFrame(() => { - tryAutoLoadMoreUntilScrollable(); - }); - }; - - window.addEventListener('resize', onResize); - return () => { - window.removeEventListener('resize', onResize); - }; - }, [activeTab, tryAutoLoadMoreUntilScrollable]); + logScrollerRef.current = scroller; const copyLogLine = async (raw: string) => { const ok = await copyToClipboard(raw); @@ -1035,8 +934,12 @@ export function LogsPage() { {loading ? (
{t('logs.loading')}
) : logState.buffer.length > 0 && filteredLines.length > 0 ? ( -
- {canLoadMore && ( +
+ {scroller.canLoadMore && (
{t('logs.load_more_hint')}
diff --git a/src/pages/hooks/useLogScroller.ts b/src/pages/hooks/useLogScroller.ts new file mode 100644 index 0000000..a19b88e --- /dev/null +++ b/src/pages/hooks/useLogScroller.ts @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import type { Dispatch, RefObject, SetStateAction, UIEvent } from 'react'; +import type { LogState } from './logTypes'; + +const LOAD_MORE_LINES = 200; +const LOAD_MORE_THRESHOLD_PX = 72; + +export const isNearBottom = (node: HTMLDivElement | null) => { + if (!node) return true; + const threshold = 24; + return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold; +}; + +interface UseLogScrollerOptions { + logState: LogState; + setLogState: Dispatch>; + loading: boolean; + isSearching: boolean; + filteredLineCount: number; + hasStructuredFilters: boolean; + showRawLogs: boolean; +} + +interface UseLogScrollerReturn { + logViewerRef: RefObject; + canLoadMore: boolean; + handleLogScroll: (e: UIEvent) => void; + scrollToBottom: () => void; + requestScrollToBottom: () => void; +} + +export function useLogScroller(options: UseLogScrollerOptions): UseLogScrollerReturn { + const { + logState, + setLogState, + loading, + isSearching, + filteredLineCount, + hasStructuredFilters, + showRawLogs, + } = options; + + const logViewerRef = useRef(null); + const pendingScrollToBottomRef = useRef(false); + const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); + + const canLoadMore = !isSearching && logState.visibleFrom > 0; + + const scrollToBottom = useCallback(() => { + const node = logViewerRef.current; + if (!node) return; + node.scrollTop = node.scrollHeight; + }, []); + + const requestScrollToBottom = useCallback(() => { + pendingScrollToBottomRef.current = true; + }, []); + + const prependVisibleLines = useCallback(() => { + const node = logViewerRef.current; + if (!node) return; + if (pendingPrependScrollRef.current) return; + if (isSearching) return; + + setLogState((prev) => { + if (prev.visibleFrom <= 0) { + return prev; + } + + pendingPrependScrollRef.current = { + scrollHeight: node.scrollHeight, + scrollTop: node.scrollTop, + }; + + return { + ...prev, + visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0), + }; + }); + }, [isSearching, setLogState]); + + const handleLogScroll = useCallback( + (_e: UIEvent) => { + const node = logViewerRef.current; + if (!node) return; + if (isSearching) return; + if (!canLoadMore) return; + if (pendingPrependScrollRef.current) return; + if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return; + + prependVisibleLines(); + }, + [canLoadMore, isSearching, prependVisibleLines] + ); + + useLayoutEffect(() => { + const node = logViewerRef.current; + const pending = pendingPrependScrollRef.current; + if (!node || !pending) return; + + const delta = node.scrollHeight - pending.scrollHeight; + node.scrollTop = pending.scrollTop + delta; + pendingPrependScrollRef.current = null; + }, [logState.visibleFrom]); + + const tryAutoLoadMoreUntilScrollable = useCallback(() => { + const node = logViewerRef.current; + if (!node) return; + if (!canLoadMore) return; + if (isSearching) return; + if (pendingPrependScrollRef.current) return; + + const hasVerticalOverflow = node.scrollHeight > node.clientHeight + 1; + if (hasVerticalOverflow) return; + + prependVisibleLines(); + }, [canLoadMore, isSearching, prependVisibleLines]); + + useEffect(() => { + if (loading) return; + const node = logViewerRef.current; + if (!node) return; + + const raf = window.requestAnimationFrame(() => { + tryAutoLoadMoreUntilScrollable(); + }); + return () => { + window.cancelAnimationFrame(raf); + }; + }, [ + filteredLineCount, + hasStructuredFilters, + loading, + logState.visibleFrom, + showRawLogs, + tryAutoLoadMoreUntilScrollable, + ]); + + useEffect(() => { + const onResize = () => { + window.requestAnimationFrame(() => { + tryAutoLoadMoreUntilScrollable(); + }); + }; + + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + }; + }, [tryAutoLoadMoreUntilScrollable]); + + useEffect(() => { + if (!pendingScrollToBottomRef.current) return; + if (loading) return; + if (!logViewerRef.current) return; + + scrollToBottom(); + pendingScrollToBottomRef.current = false; + }, [loading, logState.buffer, logState.visibleFrom, scrollToBottom]); + + return { + logViewerRef, + canLoadMore, + handleLogScroll, + scrollToBottom, + requestScrollToBottom, + }; +}