feat(ux): U2 统一 DataTable 组件 — 封装排序/搜索/分页/导出/选择/列可见性通用表格 + 迁移 RegionManager/LoginLogsTab/LogDatasourcePage 3 个页面

This commit is contained in:
attacker committed 2026-06-27 12:02:34 +08:00
1 parent bdf3c2daf4
commit ba8a4b5f19
9 files changed
+1268 -504

No files matched your search

+655
View File
@@ -0,0 +1,655 @@
/**
* 统一 DataTable 组件
*
* 封装排序/搜索/分页/行选择/CSV 导出/列可见性的通用表格。
* 基于 shadcn/ui Table + 项目 CSS 变量主题(--dg-*)。
*
* 用法示例:
* ```tsx
* <DataTable
* columns={[
* { key: 'name', header: '名称', sortable: true },
* { key: 'ip', header: 'IP', sortable: true },
* { key: 'actions', header: '操作', render: (row) => <Button>编辑</Button> },
* ]}
* 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';
// ─── Types ───────────────────────────────────────────────
export interface DataTableColumn<T = Record<string, unknown>> {
/** 唯一键名 */
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<T = Record<string, unknown>> {
/** 列定义 */
columns: DataTableColumn<T>[];
/** 数据源 */
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<T>(row: T, col: DataTableColumn<T>): string | number | Date | null {
if (col.accessor) {
const v = col.accessor(row);
return v ?? null;
}
const v = (row as Record<string, unknown>)[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<string, unknown>;
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<string, unknown>;
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<T>(
columns: DataTableColumn<T>[],
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<string, unknown>)[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<T = Record<string, unknown>>(
props: DataTableProps<T>,
) {
const {
columns,
data,
rowKey,
searchable = false,
searchPlaceholder = '搜索...',
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 = '暂无数据',
emptyRender,
onRowClick,
rowClassName,
className,
headerClassName,
bodyMaxHeight,
toolbarExtra,
} = props;
// ── Search state ──
const [searchTerm, setSearchTerm] = React.useState('');
// ── Sort state ──
const [sortKey, setSortKey] = React.useState<string | null>(null);
const [sortDir, setSortDir] = React.useState<SortDir>(null);
// ── Pagination state ──
const [currentPage, setCurrentPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(initialPageSize);
// ── Column visibility state ──
const [hiddenCols, setHiddenCols] = React.useState<Set<string>>(() => {
const s = new Set<string>();
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<string, unknown>);
return keys.some((k) => {
const v = (row as Record<string, unknown>)[k];
if (v == null) return false;
if (typeof v === 'object') {
const obj = v as Record<string, unknown>;
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<T>) => {
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 (
<div className={cn('w-full', className)}>
{/* Toolbar */}
{hasToolbar && (
<div className="flex items-center justify-between gap-2 px-1 py-2 flex-wrap">
<div className="flex items-center gap-2 flex-1 min-w-0">
{searchable && (
<div className="relative flex-shrink-0">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
<Input
type="text"
value={searchTerm}
onChange={(e) => 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)]"
/>
</div>
)}
{selectable && (selectedIds?.length ?? 0) > 0 && (
<Badge variant="secondary" className="bg-[var(--dg-bg-active)] text-[var(--dg-text-secondary)] border-[var(--dg-border)]">
已选 {selectedIds!.length} 项
</Badge>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{toolbarExtra}
{columnToggle && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]">
<Eye className="h-3.5 w-3.5" />
<span className="hidden sm:inline">列</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
{columns.filter((c) => c.hideable !== false).map((c) => (
<DropdownMenuItem
key={c.key}
onClick={() => toggleColumn(c.key)}
className="cursor-pointer text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
>
<span className={cn('inline-block w-4 h-4 mr-2 rounded-sm border border-[var(--dg-border-hover)]', !hiddenCols.has(c.key) && 'bg-blue-500 border-blue-500')}>
{!hiddenCols.has(c.key) && <span className="text-white text-[10px] flex items-center justify-center leading-none">✓</span>}
</span>
{c.header}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{exportable && (
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={sortedData.length === 0}
className="h-8 gap-1.5 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
>
<Download className="h-3.5 w-3.5" />
<span className="hidden sm:inline">导出</span>
</Button>
)}
</div>
</div>
)}
{/* Table */}
<div
className="rounded-lg overflow-hidden border border-[var(--dg-border)]"
style={bodyMaxHeight ? { maxHeight: typeof bodyMaxHeight === 'number' ? `${bodyMaxHeight}px` : bodyMaxHeight } : undefined}
>
<Table>
<TableHeader>
<TableRow className={cn('border-[var(--dg-border)] hover:bg-transparent bg-[var(--dg-bg-tertiary)]', headerClassName)}>
{selectable && (
<TableHead className="w-10 px-3">
<Checkbox
checked={allPageSelected ? true : somePageSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
className="border-[var(--dg-border-hover)]"
/>
</TableHead>
)}
{visibleColumns.map((col) => {
const isSorted = sortKey === col.key;
const canSort = globalSortable && col.sortable;
return (
<TableHead
key={col.key}
className={cn(
'text-[var(--dg-text-secondary)] font-medium select-none',
alignClass(col.align),
canSort && 'cursor-pointer hover:text-[var(--dg-text-primary)] transition-colors',
)}
style={col.width ? { width: typeof col.width === 'number' ? `${col.width}px` : col.width } : undefined}
onClick={() => handleSort(col)}
>
<span className="inline-flex items-center gap-1">
{col.header}
{canSort && (
<span className="flex-shrink-0">
{isSorted && sortDir === 'asc' ? (
<ArrowUp className="h-3 w-3 text-blue-400" />
) : isSorted && sortDir === 'desc' ? (
<ArrowDown className="h-3 w-3 text-blue-400" />
) : (
<ArrowUpDown className="h-3 w-3 opacity-40" />
)}
</span>
)}
</span>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{/* Loading skeleton */}
{loading ? (
Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
<TableRow key={`skel-${i}`} className="border-[var(--dg-border)]">
{selectable && (
<TableCell className="px-3">
<div className="h-4 w-4 rounded bg-[var(--dg-bg-hover)] animate-pulse" />
</TableCell>
)}
{visibleColumns.map((col) => (
<TableCell key={col.key} className={alignClass(col.align)}>
<div className="h-4 w-full max-w-[120px] rounded bg-[var(--dg-bg-hover)] animate-pulse" />
</TableCell>
))}
</TableRow>
))
) : /* Empty state */
paginatedData.length === 0 ? (
<TableRow className="border-[var(--dg-border)]">
<TableCell
colSpan={visibleColumns.length + (selectable ? 1 : 0)}
className="text-center text-[var(--dg-text-muted)] py-16"
>
{emptyRender ? emptyRender() : emptyText}
</TableCell>
</TableRow>
) : (
/* Data rows */
paginatedData.map((row, rowIndex) => {
const id = rowKey(row, rowIndex);
const isSelected = selectable && selectedIds?.includes(id);
return (
<TableRow
key={id}
data-selected={isSelected}
className={cn(
'border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] transition-colors',
isSelected && 'bg-blue-500/5',
onRowClick && 'cursor-pointer',
rowClassName?.(row),
)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{selectable && (
<TableCell className="px-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected ?? false}
onCheckedChange={() => handleSelectRow(id)}
className="border-[var(--dg-border-hover)]"
/>
</TableCell>
)}
{visibleColumns.map((col) => (
<TableCell key={col.key} className={cn('align-middle', alignClass(col.align))}>
{col.render ? col.render(row, rowIndex) : renderCellValue((row as Record<string, unknown>)[col.key])}
</TableCell>
))}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Footer: pagination + count */}
{enablePagination && !loading && (
<div className="flex items-center justify-between gap-2 px-1 py-2 flex-wrap">
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--dg-text-muted)]">
共 <span className="text-[var(--dg-text-secondary)] font-medium">{sortedData.length}</span> 条
</span>
{selectable && (selectedIds?.length ?? 0) > 0 && (
<span className="text-sm text-[var(--dg-text-muted)]">
/ 已选 <span className="text-blue-400 font-medium">{selectedIds!.length}</span>
</span>
)}
<div className="flex items-center gap-1.5">
<span className="text-sm text-[var(--dg-text-muted)]">每页</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 px-2 min-w-[56px] gap-1 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]">
{pageSize}
<Settings2 className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
{pageSizeOptions.map((s) => (
<DropdownMenuItem
key={s}
onClick={() => { 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}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safeCurrentPage <= 1}
className="h-7 w-7 p-0 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-[var(--dg-text-secondary)] px-2 min-w-[64px] text-center">
{safeCurrentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={safeCurrentPage >= totalPages}
className="h-7 w-7 p-0 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
// ─── Default cell value renderer ─────────────────────────
function renderCellValue(v: unknown): React.ReactNode {
if (v == null || v === '') return <span className="text-[var(--dg-text-muted)]">—</span>;
if (typeof v === 'boolean') return v ? '是' : '否';
if (typeof v === 'object') {
const obj = v as Record<string, unknown>;
const label = obj.label ?? obj.name ?? obj.value;
if (label != null) return String(label);
return JSON.stringify(v);
}
return String(v);
}