/** * 统一 DataTable 组件 * * 封装排序/搜索/分页/行选择/CSV 导出/列可见性的通用表格。 * 基于 shadcn/ui Table + 项目 CSS 变量主题(--dg-*)。 * * 用法示例: * ```tsx * }, * ]} * data={servers} * rowKey={(r) => r.id} * searchable searchPlaceholder="搜索服务器..." * pagination pageSize={20} * exportable exportFilename="servers.csv" * selectable onSelectionChange={setSelectedIds} * /> * ``` */ import * as React from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Download, Eye, Search, Settings2, } from 'lucide-react'; import { cn } from '@/lib/utils'; import i18n from '@/i18n'; // ─── Types ─────────────────────────────────────────────── export interface DataTableColumn> { /** 唯一键名 */ key: string; /** 表头文本 */ header: string; /** 列宽 */ width?: string | number; /** 是否可排序 */ sortable?: boolean; /** 排序取值函数(默认取 row[key]) */ accessor?: (row: T) => string | number | Date | null | undefined; /** 自定义渲染 */ render?: (row: T, index: number) => React.ReactNode; /** 对齐方式 */ align?: 'left' | 'center' | 'right'; /** 是否可通过列可见性面板隐藏(默认 true) */ hideable?: boolean; /** 是否默认隐藏 */ defaultHidden?: boolean; } export interface DataTableProps> { /** 列定义 */ columns: DataTableColumn[]; /** 数据源 */ data: T[]; /** 行唯一标识 */ rowKey: (row: T, index: number) => string | number; // ── 搜索 ── /** 是否启用全局搜索 */ searchable?: boolean; /** 搜索占位符 */ searchPlaceholder?: string; /** 搜索覆盖的字段 key 列表(默认搜索所有 string/number 值) */ searchKeys?: string[]; // ── 排序 ── /** 是否启用排序(列级 sortable 也要为 true) */ sortable?: boolean; // ── 分页 ── /** 是否启用客户端分页 */ pagination?: boolean; /** 每页行数 */ pageSize?: number; /** 可选每页选项 */ pageSizeOptions?: number[]; // ── 行选择 ── /** 是否启用行选择 */ selectable?: boolean; /** 受控选中 ID */ selectedIds?: (string | number)[]; /** 选中变化回调 */ onSelectionChange?: (ids: (string | number)[]) => void; // ── 导出 ── /** 是否启用 CSV 导出 */ exportable?: boolean; /** 导出文件名 */ exportFilename?: string; // ── 列可见性 ── /** 是否启用列可见性面板 */ columnToggle?: boolean; // ── 状态 ── /** 加载中 */ loading?: boolean; /** 空数据文案 */ emptyText?: string; /** 自定义空数据渲染 */ emptyRender?: () => React.ReactNode; // ── 行交互 ── /** 行点击 */ onRowClick?: (row: T) => void; /** 行样式 */ rowClassName?: (row: T) => string; // ── 布局 ── /** 容器 className */ className?: string; /** 表头额外 className */ headerClassName?: string; /** 表体最大高度(超出滚动) */ bodyMaxHeight?: string | number; /** 额外工具栏内容(右侧) */ toolbarExtra?: React.ReactNode; } // ─── Helpers ───────────────────────────────────────────── type SortDir = 'asc' | 'desc' | null; function getSortValue(row: T, col: DataTableColumn): string | number | Date | null { if (col.accessor) { const v = col.accessor(row); return v ?? null; } const v = (row as Record)[col.key]; if (v == null) return null; if (v instanceof Date) return v; if (typeof v === 'number') return v; // 尝试提取常见嵌套对象的 label/name if (typeof v === 'object') { const obj = v as Record; return String(obj.label ?? obj.name ?? obj.value ?? ''); } return String(v); } function toCsvCell(v: unknown): string { if (v == null) return ''; if (typeof v === 'object') { if (v instanceof Date) return v.toISOString(); const obj = v as Record; return String(obj.label ?? obj.name ?? obj.value ?? ''); } const s = String(v); if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; } function exportCsv( columns: DataTableColumn[], data: T[], filename: string, ): void { const headerRow = columns.map((c) => toCsvCell(c.header)).join(','); const bodyRows = data.map((row) => columns .map((c) => { if (c.accessor) return toCsvCell(c.accessor(row)); return toCsvCell((row as Record)[c.key]); }) .join(','), ); const csv = '\uFEFF' + [headerRow, ...bodyRows].join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function alignClass(align?: string): string { if (align === 'center') return 'text-center'; if (align === 'right') return 'text-right'; return 'text-left'; } // ─── Component ─────────────────────────────────────────── export function DataTable>( props: DataTableProps, ) { const { columns, data, rowKey, searchable = false, searchPlaceholder = i18n.t('ui.dataTable.s64e327'), searchKeys, sortable: globalSortable = false, pagination: enablePagination = false, pageSize: initialPageSize = 20, pageSizeOptions = [10, 20, 50, 100], selectable = false, selectedIds, onSelectionChange, exportable = false, exportFilename = 'export.csv', columnToggle = false, loading = false, emptyText = i18n.t('common.noData'), emptyRender, onRowClick, rowClassName, className, headerClassName, bodyMaxHeight, toolbarExtra, } = props; // ── Search state ── const [searchTerm, setSearchTerm] = React.useState(''); // ── Sort state ── const [sortKey, setSortKey] = React.useState(null); const [sortDir, setSortDir] = React.useState(null); // ── Pagination state ── const [currentPage, setCurrentPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(initialPageSize); // ── Column visibility state ── const [hiddenCols, setHiddenCols] = React.useState>(() => { const s = new Set(); columns.forEach((c) => { if (c.defaultHidden) s.add(c.key); }); return s; }); // ── Derived: filtered data ── const filteredData = React.useMemo(() => { if (!searchable || !searchTerm.trim()) return data; const term = searchTerm.trim().toLowerCase(); return data.filter((row) => { const keys = searchKeys ?? Object.keys(row as Record); return keys.some((k) => { const v = (row as Record)[k]; if (v == null) return false; if (typeof v === 'object') { const obj = v as Record; return String(obj.label ?? obj.name ?? obj.value ?? '').toLowerCase().includes(term); } return String(v).toLowerCase().includes(term); }); }); }, [data, searchTerm, searchable, searchKeys]); // ── Derived: sorted data ── const sortedData = React.useMemo(() => { if (!sortKey || !sortDir) return filteredData; const col = columns.find((c) => c.key === sortKey); if (!col) return filteredData; const dir = sortDir === 'asc' ? 1 : -1; return [...filteredData].sort((a, b) => { const va = getSortValue(a, col); const vb = getSortValue(b, col); if (va == null && vb == null) return 0; if (va == null) return 1; if (vb == null) return -1; if (va instanceof Date && vb instanceof Date) return (va.getTime() - vb.getTime()) * dir; if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir; return String(va).localeCompare(String(vb)) * dir; }); }, [filteredData, sortKey, sortDir, columns]); // ── Derived: paginated data ── const totalPages = enablePagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1; const safeCurrentPage = Math.min(currentPage, totalPages); const paginatedData = React.useMemo(() => { if (!enablePagination) return sortedData; const start = (safeCurrentPage - 1) * pageSize; return sortedData.slice(start, start + pageSize); }, [sortedData, safeCurrentPage, pageSize, enablePagination]); // Reset to page 1 when search changes React.useEffect(() => { if (enablePagination) setCurrentPage(1); }, [searchTerm, enablePagination]); // ── Visible columns ── const visibleColumns = React.useMemo( () => columns.filter((c) => !hiddenCols.has(c.key)), [columns, hiddenCols], ); // ── Export columns (exclude action columns that have render but no accessor/key matching) ── const exportColumns = React.useMemo(() => { return visibleColumns.filter((c) => { // Skip columns explicitly marked as not exportable or action-only if (c.key === 'actions' || c.key === 'operation' || c.key === 'ops') return false; return true; }); }, [visibleColumns]); // ── Sort handler ── const handleSort = React.useCallback( (col: DataTableColumn) => { if (!globalSortable || !col.sortable) return; if (sortKey !== col.key) { setSortKey(col.key); setSortDir('asc'); } else { const next: SortDir = sortDir === 'asc' ? 'desc' : sortDir === 'desc' ? null : 'asc'; setSortDir(next); if (next === null) setSortKey(null); } }, [globalSortable, sortKey, sortDir], ); // ── Selection handlers ── const visibleIds = paginatedData.map((row, i) => rowKey(row, i)); const allPageSelected = selectable && visibleIds.length > 0 && visibleIds.every((id) => selectedIds?.includes(id)); const somePageSelected = selectable && visibleIds.some((id) => selectedIds?.includes(id)); const handleSelectAll = React.useCallback(() => { if (!onSelectionChange) return; const current = new Set(selectedIds ?? []); if (allPageSelected) { visibleIds.forEach((id) => current.delete(id)); } else { visibleIds.forEach((id) => current.add(id)); } onSelectionChange([...current]); }, [onSelectionChange, selectedIds, allPageSelected, visibleIds]); const handleSelectRow = React.useCallback( (id: string | number) => { if (!onSelectionChange) return; const current = new Set(selectedIds ?? []); if (current.has(id)) current.delete(id); else current.add(id); onSelectionChange([...current]); }, [onSelectionChange, selectedIds], ); const handleExport = React.useCallback(() => { exportCsv(exportColumns, sortedData, exportFilename); }, [exportColumns, sortedData, exportFilename]); const toggleColumn = React.useCallback((key: string) => { setHiddenCols((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); // ── Has toolbar ── const hasToolbar = searchable || exportable || columnToggle || toolbarExtra; // ── Render ── return (
{/* Toolbar */} {hasToolbar && (
{searchable && (
setSearchTerm(e.target.value)} placeholder={searchPlaceholder} className="pl-9 h-9 w-56 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]" />
)} {selectable && (selectedIds?.length ?? 0) > 0 && ( 已选 {selectedIds!.length} 项 )}
{toolbarExtra} {columnToggle && ( {columns.filter((c) => c.hideable !== false).map((c) => ( toggleColumn(c.key)} className="cursor-pointer text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]" > {!hiddenCols.has(c.key) && ✓} {c.header} ))} )} {exportable && ( )}
)} {/* Table */}
{selectable && ( )} {visibleColumns.map((col) => { const isSorted = sortKey === col.key; const canSort = globalSortable && col.sortable; return ( handleSort(col)} > {col.header} {canSort && ( {isSorted && sortDir === 'asc' ? ( ) : isSorted && sortDir === 'desc' ? ( ) : ( )} )} ); })} {/* Loading skeleton */} {loading ? ( Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => ( {selectable && (
)} {visibleColumns.map((col) => (
))} )) ) : /* Empty state */ paginatedData.length === 0 ? ( {emptyRender ? emptyRender() : emptyText} ) : ( /* Data rows */ paginatedData.map((row, rowIndex) => { const id = rowKey(row, rowIndex); const isSelected = selectable && selectedIds?.includes(id); return ( onRowClick(row) : undefined} > {selectable && ( e.stopPropagation()}> handleSelectRow(id)} className="border-[var(--dg-border-hover)]" /> )} {visibleColumns.map((col) => ( {col.render ? col.render(row, rowIndex) : renderCellValue((row as Record)[col.key])} ))} ); }) )}
{/* Footer: pagination + count */} {enablePagination && !loading && (
共 {sortedData.length} 条 {selectable && (selectedIds?.length ?? 0) > 0 && ( / 已选 {selectedIds!.length} )}
{i18n.t('common.perPage')} {pageSizeOptions.map((s) => ( { setPageSize(s); setCurrentPage(1); }} className={cn( 'cursor-pointer text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]', s === pageSize && 'font-bold text-[var(--dg-text-primary)]', )} > {s} ))}
{safeCurrentPage} / {totalPages}
)}
); } // ─── Default cell value renderer ───────────────────────── function renderCellValue(v: unknown): React.ReactNode { if (v == null || v === '') return —; if (typeof v === 'boolean') return v ? i18n.t('common.yes') : i18n.t('common.no'); if (typeof v === 'object') { const obj = v as Record; const label = obj.label ?? obj.name ?? obj.value; if (label != null) return String(label); return JSON.stringify(v); } return String(v); }