- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架 - index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩) - 全站路由页接入 PageShell,云账单三 Tab 为标杆 - 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化 - 登录页/侧栏/顶栏去渐变光球,企业克制风 - 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
656 lines
24 KiB
TypeScript
656 lines
24 KiB
TypeScript
/**
|
||
* 统一 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);
|
||
}
|