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 (
{title}
{subtitle ?
{subtitle}
: null}
{actions ?
{actions}
: null}
);
}
export interface NoticeProps {
tone?: TagTone;
children: ReactNode;
}
/** A soft inline notice — used for "只读/后端 M10+ 提供" hints. */
export function Notice({ tone = "neutral", children }: NoticeProps): ReactNode {
return (
{tone === "warn" ? "待实现" : tone === "err" ? "错误" : "提示"}
{children}
);
}
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 (
{icon ? {icon} : null}
{label}
{value}
{sub ?
{sub}
: null}
{tone}
);
}
export function CardGrid({ children }: { children: ReactNode }): ReactNode {
return {children}
;
}
export interface AdminColumn {
key: string;
title: string;
render: (row: T) => ReactNode;
className?: string;
}
export interface AdminTableProps {
columns: AdminColumn[];
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({
columns,
rows,
loading,
error,
emptyTitle = "暂无数据",
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 加载中…
;
}
const hasRows = sorted != null && sorted.length > 0;
if (error && !hasRows) {
return {error}
;
}
if (!hasRows) {
return } title={emptyTitle} />;
}
return (
{columns.map((c) => (
|
{c.title}
|
))}
{sorted.map((row) => (
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) => (
|
{c.render(row)}
|
))}
))}
);
}
export interface KeyValueRowProps {
label: string;
value: ReactNode;
mono?: boolean;
}
export function KeyValueRow({ label, value, mono = false }: KeyValueRowProps): ReactNode {
return (
{label}
{value}
);
}