- 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模块)
168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
import { useMemo, type ReactNode } from "react";
|
|
import { EmptyState, Tag, type TagTone } from "../components/primitives";
|
|
import { Icon } from "../components/Icon";
|
|
|
|
export interface PageHeaderProps {
|
|
title: string;
|
|
subtitle?: string;
|
|
actions?: ReactNode;
|
|
}
|
|
|
|
export function PageHeader({ title, subtitle, actions }: PageHeaderProps): ReactNode {
|
|
return (
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{title}</h2>
|
|
{subtitle ? <p className="text-sm text-[var(--mu-text-lo)]">{subtitle}</p> : null}
|
|
</div>
|
|
{actions ? <div className="ml-auto flex items-center gap-2">{actions}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface NoticeProps {
|
|
tone?: TagTone;
|
|
children: ReactNode;
|
|
}
|
|
|
|
/** A soft inline notice — used for "只读/后端 M10+ 提供" hints. */
|
|
export function Notice({ tone = "neutral", children }: NoticeProps): ReactNode {
|
|
return (
|
|
<div className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 px-3 py-2 text-xs text-[var(--mu-text-mid)]">
|
|
<Tag tone={tone} className="mr-1.5">{tone === "warn" ? "待实现" : tone === "err" ? "错误" : "提示"}</Tag>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface StatCardProps {
|
|
icon?: ReactNode;
|
|
label: string;
|
|
value: ReactNode;
|
|
sub?: ReactNode;
|
|
tone?: TagTone;
|
|
}
|
|
|
|
export function StatCard({ icon, label, value, sub, tone = "accent" }: StatCardProps): ReactNode {
|
|
return (
|
|
<div className="liquid-glass flex flex-col gap-1.5 rounded-[var(--mu-radius-lg)] p-4">
|
|
<div className="flex items-center gap-2 text-xs text-[var(--mu-text-lo)]">
|
|
{icon ? <span className="text-base">{icon}</span> : null}
|
|
<span>{label}</span>
|
|
</div>
|
|
<div className="text-2xl font-semibold leading-tight">{value}</div>
|
|
{sub ? <div className="text-xs text-[var(--mu-text-lo)]">{sub}</div> : null}
|
|
<Tag tone={tone} className="w-fit">{tone}</Tag>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CardGrid({ children }: { children: ReactNode }): ReactNode {
|
|
return <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">{children}</div>;
|
|
}
|
|
|
|
export interface AdminColumn<T> {
|
|
key: string;
|
|
title: string;
|
|
render: (row: T) => ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
export interface AdminTableProps<T> {
|
|
columns: AdminColumn<T>[];
|
|
rows: readonly T[] | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
emptyTitle?: string;
|
|
rowKey: (row: T) => string;
|
|
onRowClick?: (row: T) => void;
|
|
}
|
|
|
|
/** A strict, generic data table. Loading/error/empty states are handled here so
|
|
* 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>({
|
|
columns,
|
|
rows,
|
|
loading,
|
|
error,
|
|
emptyTitle = "暂无数据",
|
|
rowKey,
|
|
onRowClick,
|
|
}: 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) {
|
|
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
|
|
}
|
|
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>;
|
|
}
|
|
if (!hasRows) {
|
|
return <EmptyState icon={<Icon name="folder" size={32} />} title={emptyTitle} />;
|
|
}
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-sm">
|
|
<thead>
|
|
<tr className="border-b border-[var(--mu-glass-border)] text-left">
|
|
{columns.map((c) => (
|
|
<th
|
|
key={c.key}
|
|
className={`px-3 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)] ${c.className ?? ""}`}
|
|
>
|
|
{c.title}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted.map((row) => (
|
|
<tr
|
|
key={rowKey(row)}
|
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
className={`border-b border-[var(--mu-glass-border)]/60 last:border-0 hover:bg-[var(--mu-surface-3)]/60 ${onRowClick ? "cursor-pointer" : ""}`}
|
|
>
|
|
{columns.map((c) => (
|
|
<td key={c.key} className={`px-3 py-2.5 align-top ${c.className ?? ""}`}>
|
|
{c.render(row)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface KeyValueRowProps {
|
|
label: string;
|
|
value: ReactNode;
|
|
mono?: boolean;
|
|
}
|
|
|
|
export function KeyValueRow({ label, value, mono = false }: KeyValueRowProps): ReactNode {
|
|
return (
|
|
<div className="flex items-start justify-between gap-4 py-1.5">
|
|
<span className="shrink-0 text-xs text-[var(--mu-text-lo)]">{label}</span>
|
|
<span className={`min-w-0 text-right text-sm text-[var(--mu-text-hi)] ${mono ? "font-mono" : ""}`}>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|