update:添加分页、成员添加优化

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 5a90f7db36
commit b7b8e28b52
20 changed files with 1729 additions and 920 deletions
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CursorPage, CursorPageMeta } from "~/services/api";
export const ADMIN_PAGE_SIZE = 10;
type Fetcher<T> = (input: {
limit: number;
cursor: string | null;
q: string;
}) => Promise<CursorPage<T>>;
/**
* Cursor-stack pagination for admin tables.
* Supports prev/next and jumping to already-visited pages.
* Changing ``q`` resets to page 1.
*/
export function useCursorPage<T>(fetcher: Fetcher<T>, q: string) {
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [meta, setMeta] = useState<CursorPageMeta>({
limit: ADMIN_PAGE_SIZE,
page_count: 0,
total_count: 0,
has_more: false,
next_cursor: null,
});
// cursor used to *enter* page N. Page 1 is always null.
const cursorByPageRef = useRef<Record<number, string | null>>({ 1: null });
const nextCursorByPageRef = useRef<Record<number, string | null>>({});
const requestIdRef = useRef(0);
const totalPages = Math.max(1, Math.ceil(meta.total_count / ADMIN_PAGE_SIZE));
const loadPage = useCallback(
async (targetPage: number, options?: { resetStack?: boolean }) => {
const requestId = ++requestIdRef.current;
setLoading(true);
try {
if (options?.resetStack) {
cursorByPageRef.current = { 1: null };
nextCursorByPageRef.current = {};
}
const cursor = cursorByPageRef.current[targetPage] ?? null;
if (targetPage > 1 && cursor === null && targetPage !== 1) {
// Unvisited deep page — refuse rather than invent offset.
return;
}
const result = await fetcher({
limit: ADMIN_PAGE_SIZE,
cursor: targetPage === 1 ? null : cursor,
q,
});
if (requestId !== requestIdRef.current) return;
setItems(result.items);
setMeta(result.meta);
setPage(targetPage);
nextCursorByPageRef.current[targetPage] = result.meta.next_cursor;
if (result.meta.next_cursor) {
cursorByPageRef.current[targetPage + 1] = result.meta.next_cursor;
}
} catch {
// Caller is responsible for notifying; keep previous items.
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
},
[fetcher, q],
);
useEffect(() => {
void loadPage(1, { resetStack: true });
}, [loadPage]);
const goNext = useCallback(() => {
if (!meta.has_more) return;
void loadPage(page + 1);
}, [loadPage, meta.has_more, page]);
const goPrev = useCallback(() => {
if (page <= 1) return;
void loadPage(page - 1);
}, [loadPage, page]);
const goToPage = useCallback(
(targetPage: number) => {
if (targetPage < 1 || targetPage > totalPages) return;
if (targetPage === page) return;
// Only allow visited pages (cursor known) or page 1.
if (targetPage !== 1 && cursorByPageRef.current[targetPage] == null) {
return;
}
void loadPage(targetPage);
},
[loadPage, page, totalPages],
);
const canGoToPage = useCallback(
(targetPage: number) => {
if (targetPage < 1 || targetPage > totalPages) return false;
if (targetPage === 1 || targetPage === page) return true;
return cursorByPageRef.current[targetPage] != null;
},
[page, totalPages],
);
const reload = useCallback(() => {
void loadPage(page);
}, [loadPage, page]);
return {
items,
setItems,
page,
loading,
meta,
totalPages,
goNext,
goPrev,
goToPage,
canGoToPage,
reload,
refreshFromStart: () => loadPage(1, { resetStack: true }),
};
}