feat(web): 定时拉取优化——静默刷新不闪loading + AdminTable稳定排序/键控不重排
- useAsyncData: dataRef跟踪,首次setLoading(骨架),已有data时不设loading(静默后台拉),成功setData/失败保留旧data - AdminTable: rows 按 rowKey 升序稳定排序(useMemo,同批数据顺序稳定不跳),稳定key diff复用DOM不整页重绘,error&&!hasRows才显示错误(保留旧表) - 受益: 所有useAsyncData/AdminTable面板页(会话/记忆/渠道/IoT/插件/人格/用户/RAG/LLM/自动化/可观测/仪表盘) - npm run build绿(83模块)
This commit is contained in:
1 parent
eaca8dc8cc
commit
e9df928072
2 files changed
+32
-8
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
import { EmptyState, Tag, type TagTone } from "../components/primitives";
|
import { EmptyState, Tag, type TagTone } from "../components/primitives";
|
||||||
import { Icon } from "../components/Icon";
|
import { Icon } from "../components/Icon";
|
||||||
|
|
||||||
@@ -79,7 +79,13 @@ export interface AdminTableProps<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A strict, generic data table. Loading/error/empty states are handled here so
|
/** 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<T>({
|
export function AdminTable<T>({
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
@@ -89,13 +95,23 @@ export function AdminTable<T>({
|
|||||||
rowKey,
|
rowKey,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
}: AdminTableProps<T>): ReactNode {
|
}: AdminTableProps<T>): 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) {
|
if (loading) {
|
||||||
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
|
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
|
||||||
}
|
}
|
||||||
if (error) {
|
const hasRows = sorted != null && sorted.length > 0;
|
||||||
|
if (error && !hasRows) {
|
||||||
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-err)]">{error}</div>;
|
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-err)]">{error}</div>;
|
||||||
}
|
}
|
||||||
if (!rows || rows.length === 0) {
|
if (!hasRows) {
|
||||||
return <EmptyState icon={<Icon name="folder" size={32} />} title={emptyTitle} />;
|
return <EmptyState icon={<Icon name="folder" size={32} />} title={emptyTitle} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -114,7 +130,7 @@ export function AdminTable<T>({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{sorted.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
|||||||
@@ -8,18 +8,26 @@ interface AsyncState<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Runs an async factory on mount (and on reload), tracking loading/error so
|
/** 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<T>(fn: () => Promise<T>, deps: readonly unknown[] = []): AsyncState<T> {
|
export function useAsyncData<T>(fn: () => Promise<T>, deps: readonly unknown[] = []): AsyncState<T> {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const dataRef = useRef<T | null>(null);
|
||||||
|
dataRef.current = data;
|
||||||
const fnRef = useRef(fn);
|
const fnRef = useRef(fn);
|
||||||
fnRef.current = fn;
|
fnRef.current = fn;
|
||||||
const depsKey = deps.join("\u0000");
|
const depsKey = deps.join("\u0000");
|
||||||
|
|
||||||
const run = useCallback(() => {
|
const run = useCallback(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
const first = dataRef.current == null;
|
||||||
|
if (first) setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
fnRef
|
fnRef
|
||||||
.current()
|
.current()
|
||||||
@@ -30,7 +38,7 @@ export function useAsyncData<T>(fn: () => Promise<T>, deps: readonly unknown[] =
|
|||||||
if (!cancelled) setError(err instanceof Error ? err.message : "加载失败");
|
if (!cancelled) setError(err instanceof Error ? err.message : "加载失败");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled && first) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
Reference in new issue
Block a user