Files
aiops/frontend/src/components/ui/data-table.tsx
T
Logan e363ded445 feat: 全站 PageShell 设计系统与专业 UI 统一
- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架
- index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩)
- 全站路由页接入 PageShell,云账单三 Tab 为标杆
- 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化
- 登录页/侧栏/顶栏去渐变光球,企业克制风
- 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
2026-07-01 12:45:38 +08:00

656 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 统一 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 dg-text-blue" />
) : isSorted && sortDir === 'desc' ? (
<ArrowDown className="h-3 w-3 dg-text-blue" />
) : (
<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="dg-text-blue 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);
}