import { useRef, useState, useCallback, useEffect, type FC } from 'react'; import { Select, Spin, Avatar } from 'antd'; import type { SelectProps, DefaultOptionType } from 'antd/es/select'; import { request } from '@/utils/request'; interface OptionType { [key: string]: any; } interface ApiResponse { items?: T[]; page: { hasnext: boolean }; } export interface DebounceSelectProps extends Omit { /** API endpoint URL — mutually exclusive with fetchOptions */ url?: string; /** Extra query params merged with the search keyword */ params?: Record; /** Key used as option value */ valueKey?: string; /** Key used as option label */ labelKey?: string; /** Key name sent to the API for the search keyword */ searchKey?: string; pageSize?: number; /** Custom fetch function — mutually exclusive with url */ fetchOptions?: (search: string | null, page: number) => Promise<{ options: DefaultOptionType[]; hasMore: boolean }>; /** Transform raw API items before rendering */ format?: (items: OptionType[]) => OptionType[]; debounceTimeout?: number; } const DebounceSelect: FC = ({ url, params = {}, valueKey = 'value', labelKey = 'label', searchKey = 'search', pageSize = 20, fetchOptions, format, debounceTimeout = 300, ...props }) => { const [fetching, setFetching] = useState(false); const [options, setOptions] = useState([]); const [hasMore, setHasMore] = useState(true); const pageRef = useRef(1); const keywordRef = useRef(null); const fetchRef = useRef(0); const timerRef = useRef>(); const fetchPage = useCallback((keyword: string | null, page: number, replace: boolean) => { fetchRef.current += 1; const fetchId = fetchRef.current; setFetching(true); const promise = fetchOptions ? fetchOptions(keyword, page) : request .get>(url!, { ...params, [searchKey]: keyword, page, pagesize: pageSize }) .then((res) => { const data: OptionType[] = Array.isArray(res) ? res : res?.items || []; const formatted = format ? format(data) : data.map((item) => ({ label: item[labelKey], value: item[valueKey], avatar: item.avatar, raw: item })); console.log('more', res.page?.hasnext) return { options: formatted, hasMore: res.page?.hasnext }; }); promise .then(({ options: newOptions, hasMore: more }) => { if (fetchId !== fetchRef.current) return; setOptions((prev) => (replace ? newOptions : [...prev, ...newOptions])); setHasMore(more); setFetching(false); }) .catch(() => setFetching(false)); }, [url, params, searchKey, fetchOptions, format, valueKey, labelKey, pageSize]); const debounceFetcher = useCallback((keyword: string | null) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { keywordRef.current = keyword; pageRef.current = 1; fetchPage(keyword, 1, true); }, debounceTimeout); }, [fetchPage, debounceTimeout]); useEffect(() => { debounceFetcher(null); }, []); const handlePopupScroll = useCallback((e: React.UIEvent) => { const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; if (!fetching && hasMore && scrollHeight - scrollTop - clientHeight < 50) { const nextPage = pageRef.current + 1; pageRef.current = nextPage; fetchPage(keywordRef.current, nextPage, false); } }, [fetching, hasMore, fetchPage]); return (