import React, { type ReactNode, type RefObject, useMemo, useRef, useState } from 'react'; import { Box, type BoxProps } from '@chakra-ui/react'; import { useToast } from './useToast'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { type PaginationProps, type PaginationResponse } from '../common/fetch/type'; import { useBoolean, useLockFn, useMemoizedFn, useScroll, useVirtualList, useRequest, useThrottleEffect } from 'ahooks'; import MyBox from '../components/common/MyBox'; import { useTranslation } from 'next-i18next'; import { useRequest2 } from './useRequest'; type ItemHeight = (index: number, data: T) => number; const thresholdVal = 100; export type ScrollListType = ({ children, EmptyChildren, isLoading, ...props }: { children: React.ReactNode; EmptyChildren?: React.ReactNode; isLoading?: boolean; } & BoxProps) => React.JSX.Element; export function useVirtualScrollPagination< TParams extends PaginationProps, TData extends PaginationResponse >( api: (data: TParams) => Promise, { refreshDeps, itemHeight = 50, overscan = 10, pageSize = 10, defaultParams = {} }: { refreshDeps?: any[]; itemHeight: number | ItemHeight; overscan?: number; pageSize?: number; defaultParams?: Record; } ) { const { t } = useTranslation(); const containerRef = useRef(null); const wrapperRef = useRef(null); const { toast } = useToast(); const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [isLoading, { setTrue, setFalse }] = useBoolean(false); const noMore = data.length >= total; const [list] = useVirtualList(data, { containerTarget: containerRef, wrapperTarget: wrapperRef, itemHeight, overscan }); const loadData = useLockFn(async (init = false) => { if (noMore && !init) return; const offset = init ? 0 : data.length; setTrue(); try { const res = await api({ offset, pageSize, ...defaultParams } as TParams); setTotal(res.total); if (offset === 0) { // init or reload setData(res.list); } else { setData((prev) => [...prev, ...res.list]); } } catch (error: any) { toast({ title: getErrText(error, t('common:core.chat.error.data_error')), status: 'error' }); console.log(error); } setFalse(); }); const scroll2Top = () => { if (containerRef.current) { containerRef.current.scrollTop = 0; } }; const ScrollList = useMemoizedFn( ({ children, EmptyChildren, isLoading, ...props }: { children: React.ReactNode; EmptyChildren?: React.ReactNode; isLoading?: boolean; } & BoxProps) => { return ( {children} {noMore && list.length > 0 && ( {t('common:no_more_data')} )} {list.length === 0 && !isLoading && EmptyChildren && <>{EmptyChildren}} ); } ); // Reload data useRequest( async () => { loadData(true); }, { manual: false, refreshDeps } ); // Check if scroll to bottom const scroll = useScroll(containerRef); useThrottleEffect( () => { if (!containerRef.current || list.length === 0) return; const { scrollTop, scrollHeight, clientHeight } = containerRef.current; if (scrollTop + clientHeight >= scrollHeight - thresholdVal) { loadData(false); } }, [scroll], { wait: 50 } ); return { containerRef, scrollDataList: list, total, totalData: data, setData, isLoading, ScrollList, fetchData: loadData, scroll2Top }; } export function useScrollPagination< TParams extends PaginationProps, TData extends PaginationResponse >( api: (data: TParams) => Promise, { scrollLoadType = 'bottom', pageSize = 10, params = {}, EmptyTip, showErrorToast = true, disalbed = false, ...props }: { scrollLoadType?: 'top' | 'bottom'; pageSize?: number; params?: Record; EmptyTip?: React.JSX.Element; showErrorToast?: boolean; disalbed?: boolean; } & Parameters[1] ) { const { t } = useTranslation(); const { toast } = useToast(); const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [isLoading, { setTrue, setFalse }] = useBoolean(false); const isEmpty = total === 0 && !isLoading; const noMore = data.length >= total; const loadData = useLockFn( async (init = false, ScrollContainerRef?: RefObject) => { if (noMore && !init) return; setTrue(); if (init) { setData([]); setTotal(0); } const offset = init ? 0 : data.length; try { const res = await api({ offset, pageSize, ...params } as TParams); setTotal(res.total); if (scrollLoadType === 'top') { const prevHeight = ScrollContainerRef?.current?.scrollHeight || 0; const prevScrollTop = ScrollContainerRef?.current?.scrollTop || 0; // 使用 requestAnimationFrame 来调整滚动位置 function adjustScrollPosition() { requestAnimationFrame( ScrollContainerRef?.current ? () => { if (ScrollContainerRef?.current) { const newHeight = ScrollContainerRef.current.scrollHeight; const heightDiff = newHeight - prevHeight; ScrollContainerRef.current.scrollTop = prevScrollTop + heightDiff; } } : adjustScrollPosition ); } setData((prevData) => (offset === 0 ? res.list : [...res.list, ...prevData])); adjustScrollPosition(); } else { setData((prevData) => (offset === 0 ? res.list : [...prevData, ...res.list])); } } catch (error: any) { if (showErrorToast) { toast({ title: getErrText(error, t('common:core.chat.error.data_error')), status: 'error' }); } console.log(error); } setFalse(); } ); let ScrollRef = useRef(null); const ScrollData = useMemoizedFn( ({ children, ScrollContainerRef, isLoading: isLoadingProp, ...props }: { isLoading?: boolean; children: ReactNode; ScrollContainerRef?: RefObject; } & BoxProps) => { const ref = ScrollContainerRef || ScrollRef; const loadText = useMemo(() => { if (isLoading || isLoadingProp) return t('common:is_requesting'); if (noMore) return t('common:request_end'); return t('common:request_more'); }, [isLoading, noMore]); const scroll = useScroll(ref); // Watch scroll position useThrottleEffect( () => { if (!ref?.current || noMore || isLoading || data.length === 0) return; const { scrollTop, scrollHeight, clientHeight } = ref.current; if ( (scrollLoadType === 'bottom' && scrollTop + clientHeight >= scrollHeight - thresholdVal) || (scrollLoadType === 'top' && scrollTop < thresholdVal) ) { loadData(false, ref); } }, [scroll], { wait: 50 } ); return ( {scrollLoadType === 'top' && total > 0 && isLoading && ( {t('common:is_requesting')} )} {children} {scrollLoadType === 'bottom' && !isEmpty && ( { if (loadText !== t('common:request_more')) return; loadData(false); }} > {loadText} )} {isEmpty && EmptyTip} ); } ); // Reload data useRequest2( async () => { if (disalbed) return; loadData(true); }, { manual: false, ...props } ); const refreshList = useMemoizedFn(() => { loadData(true); }); return { ScrollData, isLoading, total: Math.max(total, data.length), data, setData, fetchData: loadData, refreshList }; }