fix(web): DebounceSelect support page load
This commit is contained in:
@@ -10,6 +10,7 @@ interface OptionType {
|
|||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
items?: T[];
|
items?: T[];
|
||||||
|
page: { hasnext: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DebounceSelectProps extends Omit<SelectProps, 'options'> {
|
export interface DebounceSelectProps extends Omit<SelectProps, 'options'> {
|
||||||
@@ -23,8 +24,9 @@ export interface DebounceSelectProps extends Omit<SelectProps, 'options'> {
|
|||||||
labelKey?: string;
|
labelKey?: string;
|
||||||
/** Key name sent to the API for the search keyword */
|
/** Key name sent to the API for the search keyword */
|
||||||
searchKey?: string;
|
searchKey?: string;
|
||||||
|
pageSize?: number;
|
||||||
/** Custom fetch function — mutually exclusive with url */
|
/** Custom fetch function — mutually exclusive with url */
|
||||||
fetchOptions?: (search: string | null) => Promise<DefaultOptionType[]>;
|
fetchOptions?: (search: string | null, page: number) => Promise<{ options: DefaultOptionType[]; hasMore: boolean }>;
|
||||||
/** Transform raw API items before rendering */
|
/** Transform raw API items before rendering */
|
||||||
format?: (items: OptionType[]) => OptionType[];
|
format?: (items: OptionType[]) => OptionType[];
|
||||||
debounceTimeout?: number;
|
debounceTimeout?: number;
|
||||||
@@ -32,10 +34,11 @@ export interface DebounceSelectProps extends Omit<SelectProps, 'options'> {
|
|||||||
|
|
||||||
const DebounceSelect: FC<DebounceSelectProps> = ({
|
const DebounceSelect: FC<DebounceSelectProps> = ({
|
||||||
url,
|
url,
|
||||||
params = { page: 1, pagesize: 20 },
|
params = {},
|
||||||
valueKey = 'value',
|
valueKey = 'value',
|
||||||
labelKey = 'label',
|
labelKey = 'label',
|
||||||
searchKey = 'search',
|
searchKey = 'search',
|
||||||
|
pageSize = 20,
|
||||||
fetchOptions,
|
fetchOptions,
|
||||||
format,
|
format,
|
||||||
debounceTimeout = 300,
|
debounceTimeout = 300,
|
||||||
@@ -43,56 +46,81 @@ const DebounceSelect: FC<DebounceSelectProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
const [options, setOptions] = useState<DefaultOptionType[]>([]);
|
const [options, setOptions] = useState<DefaultOptionType[]>([]);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const pageRef = useRef(1);
|
||||||
|
const keywordRef = useRef<string | null>(null);
|
||||||
const fetchRef = useRef(0);
|
const fetchRef = useRef(0);
|
||||||
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
// Load initial options on mount
|
const fetchPage = useCallback((keyword: string | null, page: number, replace: boolean) => {
|
||||||
useEffect(() => {
|
fetchRef.current += 1;
|
||||||
debounceFetcher(null);
|
const fetchId = fetchRef.current;
|
||||||
}, []);
|
setFetching(true);
|
||||||
|
|
||||||
|
const promise = fetchOptions
|
||||||
|
? fetchOptions(keyword, page)
|
||||||
|
: request
|
||||||
|
.get<ApiResponse<OptionType>>(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) => {
|
const debounceFetcher = useCallback((keyword: string | null) => {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
fetchRef.current += 1;
|
keywordRef.current = keyword;
|
||||||
const fetchId = fetchRef.current;
|
pageRef.current = 1;
|
||||||
setOptions([]);
|
fetchPage(keyword, 1, true);
|
||||||
setFetching(true);
|
|
||||||
|
|
||||||
const promise: Promise<DefaultOptionType[]> = fetchOptions
|
|
||||||
? fetchOptions(keyword)
|
|
||||||
: request
|
|
||||||
.get<ApiResponse<OptionType>>(url!, { ...params, [searchKey]: keyword })
|
|
||||||
.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,
|
|
||||||
}));
|
|
||||||
return formatted;
|
|
||||||
});
|
|
||||||
|
|
||||||
promise
|
|
||||||
.then((newOptions) => {
|
|
||||||
if (fetchId !== fetchRef.current) return;
|
|
||||||
setOptions(newOptions);
|
|
||||||
setFetching(false);
|
|
||||||
})
|
|
||||||
.catch(() => setFetching(false));
|
|
||||||
}, debounceTimeout);
|
}, debounceTimeout);
|
||||||
}, [url, params, searchKey, fetchOptions, format, valueKey, labelKey, debounceTimeout]);
|
}, [fetchPage, debounceTimeout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
debounceFetcher(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePopupScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<Select
|
<Select
|
||||||
labelInValue
|
labelInValue
|
||||||
filterOption={false}
|
filterOption={false}
|
||||||
onSearch={debounceFetcher}
|
onSearch={debounceFetcher}
|
||||||
|
onPopupScroll={handlePopupScroll}
|
||||||
notFoundContent={fetching ? <Spin size="small" /> : null}
|
notFoundContent={fetching ? <Spin size="small" /> : null}
|
||||||
|
allowClear
|
||||||
{...props}
|
{...props}
|
||||||
options={options}
|
options={options}
|
||||||
|
dropdownRender={(menu) => (
|
||||||
|
<>
|
||||||
|
{menu}
|
||||||
|
{fetching && options.length > 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '4px 0' }}><Spin size="small" /></div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
optionRender={(option) => (
|
optionRender={(option) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{option.data.avatar && <Avatar src={option.data.avatar} size="small" />}
|
{option.data.avatar && <Avatar src={option.data.avatar} size="small" />}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-02 15:07:49
|
* @Date: 2026-02-02 15:07:49
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-04-03 20:21:02
|
* @Last Modified time: 2026-04-07 12:18:58
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* AppHeader Component
|
* AppHeader Component
|
||||||
@@ -77,7 +77,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
|
|||||||
{
|
{
|
||||||
key: '1',
|
key: '1',
|
||||||
icon: <Flex align="center" justify="center" className="rb:size-10 rb:rounded-xl rb:bg-[#155EEF] rb:text-white">
|
icon: <Flex align="center" justify="center" className="rb:size-10 rb:rounded-xl rb:bg-[#155EEF] rb:text-white">
|
||||||
{/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(0, 2) : user.username[0]}
|
{/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(0, 2) : user.username?.[0]}
|
||||||
</Flex>,
|
</Flex>,
|
||||||
label: (<>
|
label: (<>
|
||||||
<div className="rb:text-[#212332] rb:leading-5">{user.username}</div>
|
<div className="rb:text-[#212332] rb:leading-5">{user.username}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user