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) => ( ))} {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.title}
{c.render(row)}
); } export interface KeyValueRowProps { label: string; value: ReactNode; mono?: boolean; } export function KeyValueRow({ label, value, mono = false }: KeyValueRowProps): ReactNode { return (
{label} {value}
); }