diff --git a/app/hosts/[hostname]/components/ScreenshotsTab.tsx b/app/hosts/[hostname]/components/ScreenshotsTab.tsx index 14c9cce..34bd984 100644 --- a/app/hosts/[hostname]/components/ScreenshotsTab.tsx +++ b/app/hosts/[hostname]/components/ScreenshotsTab.tsx @@ -14,20 +14,22 @@ import { useStarToggle } from '../hooks/useStarToggle'; const inFlightJsonRequests = new Map>(); +const fetchJson = async (url: string, signal?: AbortSignal): Promise => { + const response = await fetch(url, { cache: 'no-store', signal }); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + return response.json() as Promise; +}; + const fetchJsonOnce = async (url: string): Promise => { const existingRequest = inFlightJsonRequests.get(url); if (existingRequest) { return existingRequest as Promise; } - const request = fetch(url, { cache: 'no-store' }) - .then(async (response) => { - if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`); - } - - return response.json() as Promise; - }) + const request = fetchJson(url) .finally(() => { inFlightJsonRequests.delete(url); }); @@ -36,6 +38,12 @@ const fetchJsonOnce = async (url: string): Promise => { return request as Promise; }; +const isAbortError = (error: unknown) => ( + error instanceof DOMException && error.name === 'AbortError' +); + +const HOURLY_RECORDS_REQUEST_DEBOUNCE_MS = 120; + interface ScreenshotsTabProps { hostname: string; selectedDate: string | null; @@ -53,6 +61,8 @@ export default function ScreenshotsTab({ }: ScreenshotsTabProps) { const timeDistributionRequestIdRef = useRef(0); const hourlyRecordsRequestIdRef = useRef(0); + const hourlyRecordsAbortControllerRef = useRef(null); + const hourlyRecordsDebounceTimerRef = useRef | null>(null); // 状态管理 const [timeDistribution, setTimeDistribution] = useState([]); @@ -83,6 +93,25 @@ export default function ScreenshotsTab({ const { updatingStars, toggleStar } = useStarToggle(); + const clearPendingHourlyRecordsRequest = () => { + if (hourlyRecordsDebounceTimerRef.current) { + clearTimeout(hourlyRecordsDebounceTimerRef.current); + hourlyRecordsDebounceTimerRef.current = null; + } + }; + + const scheduleHourlyRecordsFetch = ( + startTime: number, + endTime: number, + options?: { targetRecordId?: string, keepSelection?: boolean } + ) => { + clearPendingHourlyRecordsRequest(); + hourlyRecordsDebounceTimerRef.current = setTimeout(() => { + hourlyRecordsDebounceTimerRef.current = null; + fetchHourlyRecords(startTime, endTime, options); + }, HOURLY_RECORDS_REQUEST_DEBOUNCE_MS); + }; + // 获取时间分布数据 const fetchTimeDistribution = async () => { const requestId = ++timeDistributionRequestIdRef.current; @@ -112,19 +141,23 @@ export default function ScreenshotsTab({ // 获取小时记录 const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => { + clearPendingHourlyRecordsRequest(); const requestId = ++hourlyRecordsRequestIdRef.current; const requestUrl = `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`; + hourlyRecordsAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + hourlyRecordsAbortControllerRef.current = abortController; try { setLoadingRecords(true); setShowDetailTimeline(true); - const data = await fetchJsonOnce<{ + const data = await fetchJson<{ lastUpdate: string | null; records: ScreenRecord[]; - }>(requestUrl); + }>(requestUrl, abortController.signal); - if (requestId !== hourlyRecordsRequestIdRef.current) { + if (requestId !== hourlyRecordsRequestIdRef.current || abortController.signal.aborted) { return; } @@ -134,6 +167,10 @@ export default function ScreenshotsTab({ setTimeRange({ min: startTime * 1000, max: endTime * 1000 }); requestAnimationFrame(() => { + if (requestId !== hourlyRecordsRequestIdRef.current || abortController.signal.aborted) { + return; + } + if (options?.targetRecordId) { const found = newRecords.find((r: ScreenRecord) => r.id === options.targetRecordId); if (found) { @@ -151,11 +188,19 @@ export default function ScreenshotsTab({ } }); } catch (error) { + if (isAbortError(error)) { + return; + } + if (requestId === hourlyRecordsRequestIdRef.current) { console.error('获取记录数据失败:', error); } } finally { - if (requestId === hourlyRecordsRequestIdRef.current) { + if (hourlyRecordsAbortControllerRef.current === abortController) { + hourlyRecordsAbortControllerRef.current = null; + } + + if (requestId === hourlyRecordsRequestIdRef.current && !abortController.signal.aborted) { setLoadingRecords(false); } } @@ -273,12 +318,8 @@ export default function ScreenshotsTab({ // 事件处理函数 const onHourlySliderChange = (newValue: number) => { const selectedSec = Math.floor(newValue / 3600000) * 3600; - - // 使用 setTimeout 避免在渲染过程中更新状态 - setTimeout(() => { - setHourlySliderValue(newValue); - fetchHourlyRecords(selectedSec, selectedSec + 3600); - }, 0); + setHourlySliderValue(newValue); + scheduleHourlyRecordsFetch(selectedSec, selectedSec + 3600); }; const onDetailedSliderChange = (newValue: number) => { @@ -443,6 +484,19 @@ export default function ScreenshotsTab({ }, [prevFrame, nextFrame]); // Effects + useEffect(() => { + return () => { + if (hourlyRecordsDebounceTimerRef.current) { + clearTimeout(hourlyRecordsDebounceTimerRef.current); + hourlyRecordsDebounceTimerRef.current = null; + } + + hourlyRecordsRequestIdRef.current += 1; + hourlyRecordsAbortControllerRef.current?.abort(); + hourlyRecordsAbortControllerRef.current = null; + }; + }, []); + useEffect(() => { fetchTimeDistribution(); }, [hostname]);