diff --git a/frontend/web/src/admin/components.tsx b/frontend/web/src/admin/components.tsx index 6eb96d3..790f5a3 100644 --- a/frontend/web/src/admin/components.tsx +++ b/frontend/web/src/admin/components.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { EmptyState, Tag, type TagTone } from "../components/primitives"; import { Icon } from "../components/Icon"; @@ -79,7 +79,13 @@ export interface AdminTableProps { } /** A strict, generic data table. Loading/error/empty states are handled here so - * every module has a complete surface without duplicating state rendering. */ + * every module has a complete surface without duplicating state rendering. + * + * Rows are sorted by `rowKey` so the order is deterministic no matter how the + * backend returns them — the same data always renders in the same order (no + * jumping on refresh). Stable keys let React reuse the row DOM and only update + * changed cells. An error is shown only when there is no data to keep on + * screen, so a failed silent refresh keeps the stale list visible. */ export function AdminTable({ columns, rows, @@ -89,13 +95,23 @@ export function AdminTable({ rowKey, onRowClick, }: AdminTableProps): ReactNode { + const sorted = useMemo(() => { + if (!rows) return rows; + return [...rows].sort((a, b) => { + const ka = rowKey(a); + const kb = rowKey(b); + return ka < kb ? -1 : ka > kb ? 1 : 0; + }); + }, [rows, rowKey]); + if (loading) { return
加载中…
; } - if (error) { + const hasRows = sorted != null && sorted.length > 0; + if (error && !hasRows) { return
{error}
; } - if (!rows || rows.length === 0) { + if (!hasRows) { return } title={emptyTitle} />; } return ( @@ -114,7 +130,7 @@ export function AdminTable({ - {rows.map((row) => ( + {sorted.map((row) => ( onRowClick(row) : undefined} diff --git a/frontend/web/src/lib/useAsyncData.ts b/frontend/web/src/lib/useAsyncData.ts index 475ebde..a53e7bf 100644 --- a/frontend/web/src/lib/useAsyncData.ts +++ b/frontend/web/src/lib/useAsyncData.ts @@ -8,18 +8,26 @@ interface AsyncState { } /** Runs an async factory on mount (and on reload), tracking loading/error so - * every API-backed surface has a complete state. Best-effort, no `any`. */ + * every API-backed surface has a complete state. Best-effort, no `any`. + * + * Reloads after the first load are *silent*: once data is on screen the old + * list is kept (no loading flicker) and only replaced when fresh data lands. + * If a silent refresh fails, the old data is preserved and the error recorded + * for the next state read. The first (data-less) load still shows loading. */ export function useAsyncData(fn: () => Promise, deps: readonly unknown[] = []): AsyncState { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const dataRef = useRef(null); + dataRef.current = data; const fnRef = useRef(fn); fnRef.current = fn; const depsKey = deps.join("\u0000"); const run = useCallback(() => { let cancelled = false; - setLoading(true); + const first = dataRef.current == null; + if (first) setLoading(true); setError(null); fnRef .current() @@ -30,7 +38,7 @@ export function useAsyncData(fn: () => Promise, deps: readonly unknown[] = if (!cancelled) setError(err instanceof Error ? err.message : "加载失败"); }) .finally(() => { - if (!cancelled) setLoading(false); + if (!cancelled && first) setLoading(false); }); return () => { cancelled = true;