Merge pull request #795 from SuanmoSuanyangTechnology/feature/ui_upgrade_zy

fix(web): DebounceSelect support page load
This commit is contained in:
yingzhao
2026-04-07 12:26:05 +08:00
committed by GitHub
2 changed files with 65 additions and 37 deletions

View File

@@ -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" />}

View File

@@ -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>