import { ChevronRight, type LucideIcon } from "lucide-react"; import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react"; import { Button } from "~/components/ui/button.tsx"; import { cn } from "~/lib/utils.ts"; import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./data-table-groups.ts"; import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx"; export type DataColumn = { /** 列 key(React key 用) */ key: string; header: ReactNode; cell: (row: T) => ReactNode; /** 窄屏隐藏该列,避免横向溢出 */ hideBelow?: "sm" | "md" | "lg"; align?: "left" | "right"; className?: string; }; /** 分组:按键把行归组,组头可折叠并显示计数;给 storageKey 时在本浏览器记住折叠的组 */ export type DataGroupBy = { key: (row: T) => string; /** 组头文字,缺省为 key */ label?: (key: string, rows: T[]) => ReactNode; /** 组顺序,缺省按首次出现 */ order?: (a: string, b: string) => number; storageKey?: string; }; const HIDE: Record["hideBelow"]>, string> = { sm: "hidden sm:table-cell", md: "hidden md:table-cell", lg: "hidden lg:table-cell", }; /** * 数据表:内置 loading / error / empty 三态与"加载更多"游标翻页。 * 外层自带 pg-slab 卡片(plain 时不带);表格在卡片内横向滚动,页面本身不溢出。 * * r.id} * loading={q.isPending} error={q.error} onRetry={q.refetch} * empty={{ title: "还没有记录" }} onRowClick={(r) => open(r.id)} * hasMore={!!next} onLoadMore={...} loadingMore={...} /> */ export function DataTable({ columns, rows, rowKey, loading = false, error, onRetry, empty, onRowClick, selectedKey, hasMore = false, onLoadMore, loadingMore = false, plain = false, groupBy, className, }: { columns: DataColumn[]; rows: T[] | undefined; rowKey: (row: T) => string; loading?: boolean; error?: unknown; onRetry?: () => void; empty?: { title: string; description?: ReactNode; icon?: LucideIcon; action?: ReactNode }; onRowClick?: (row: T) => void; selectedKey?: string | null; hasMore?: boolean; onLoadMore?: () => void; loadingMore?: boolean; plain?: boolean; groupBy?: DataGroupBy; className?: string; }) { const storageKey = groupBy?.storageKey; const [collapsed, setCollapsed] = useState>(() => new Set()); // 折叠态在挂载后读(服务端渲染时没有 localStorage,首屏一律展开) useEffect(() => { const saved = readCollapsed(storageKey); if (saved) setCollapsed(saved); }, [storageKey]); const groupKey = groupBy?.key; const groupOrder = groupBy?.order; const groups = useMemo( () => (groupKey && rows ? groupRows(rows, groupKey, groupOrder) : null), [rows, groupKey, groupOrder], ); const toggleGroup = (k: string) => setCollapsed((prev) => { const next = toggleCollapsed(prev, k); writeCollapsed(storageKey, next); return next; }); const renderRow = (row: T) => { const key = rowKey(row); return ( onRowClick(row) : undefined} onKeyDown={ onRowClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(row); } } : undefined } tabIndex={onRowClick ? 0 : undefined} aria-selected={selectedKey != null ? selectedKey === key : undefined} className={cn( "border-b border-border/60 transition-colors duration-micro last:border-b-0", onRowClick && "cursor-pointer outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]", selectedKey === key && "bg-foreground/[0.05]", )} > {columns.map((c) => ( {c.cell(row)} ))} ); }; let body: ReactNode; if (loading && !rows) { body = ; } else if (error && !rows) { body = ; } else if (!rows || rows.length === 0) { body = ( ); } else { body = (
{columns.map((c) => ( ))} {groups ? ( groups.map((g) => { const open = !collapsed.has(g.key); return ( {open ? {g.rows.map(renderRow)} : null} ); }) ) : ( {rows.map(renderRow)} )}
{c.header}
); } return (
{body} {rows && rows.length > 0 && (hasMore || error) ? (
{error ? ( {String((error as Error).message ?? error)} ) : null} {hasMore && onLoadMore ? ( ) : null}
) : null}
); }