417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import { ChevronUp, ChevronDown, Filter } from 'lucide-react';
|
||
import { SearchInput } from './InteractiveInput';
|
||
import { InteractiveButton } from './InteractiveButton';
|
||
|
||
export interface Column<T> {
|
||
key: keyof T | string;
|
||
title: string;
|
||
width?: string;
|
||
sortable?: boolean;
|
||
filterable?: boolean;
|
||
render?: (value: any, record: T, index: number) => React.ReactNode;
|
||
align?: 'left' | 'center' | 'right';
|
||
}
|
||
|
||
export interface TableAction<T> {
|
||
key: string;
|
||
label: string;
|
||
icon?: React.ReactNode;
|
||
onClick: (record: T) => void;
|
||
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'ghost' | 'outline';
|
||
disabled?: (record: T) => boolean;
|
||
}
|
||
|
||
interface DataTableProps<T> {
|
||
data: T[];
|
||
columns: Column<T>[];
|
||
actions?: TableAction<T>[];
|
||
loading?: boolean;
|
||
searchable?: boolean;
|
||
searchPlaceholder?: string;
|
||
filterable?: boolean;
|
||
sortable?: boolean;
|
||
pagination?: boolean;
|
||
pageSize?: number;
|
||
emptyText?: string;
|
||
className?: string;
|
||
rowKey?: keyof T | ((record: T) => string);
|
||
onRowClick?: (record: T) => void;
|
||
selectedRows?: T[];
|
||
onSelectionChange?: (selectedRows: T[]) => void;
|
||
bulkActions?: TableAction<T[]>[];
|
||
}
|
||
|
||
/**
|
||
* 增强的数据表格组件
|
||
* 支持搜索、排序、筛选、分页等功能
|
||
*/
|
||
export function DataTable<T extends Record<string, any>>({
|
||
data,
|
||
columns,
|
||
actions = [],
|
||
loading = false,
|
||
searchable = true,
|
||
searchPlaceholder = '搜索...',
|
||
filterable = false,
|
||
sortable = true,
|
||
pagination = true,
|
||
pageSize = 10,
|
||
emptyText = '暂无数据',
|
||
className = '',
|
||
rowKey = 'id',
|
||
onRowClick,
|
||
selectedRows = [],
|
||
onSelectionChange,
|
||
bulkActions = [],
|
||
}: DataTableProps<T>) {
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [sortConfig, setSortConfig] = useState<{
|
||
key: string;
|
||
direction: 'asc' | 'desc';
|
||
} | null>(null);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
// 获取行的唯一键
|
||
const getRowKey = (record: T, index: number): string => {
|
||
if (typeof rowKey === 'function') {
|
||
return rowKey(record);
|
||
}
|
||
return record[rowKey] || index.toString();
|
||
};
|
||
|
||
// 搜索过滤
|
||
const searchedData = useMemo(() => {
|
||
if (!searchQuery.trim()) return data;
|
||
|
||
return data.filter(record => {
|
||
return columns.some(column => {
|
||
const value = record[column.key as keyof T];
|
||
return String(value).toLowerCase().includes(searchQuery.toLowerCase());
|
||
});
|
||
});
|
||
}, [data, searchQuery, columns]);
|
||
|
||
// 排序
|
||
const sortedData = useMemo(() => {
|
||
if (!sortConfig) return searchedData;
|
||
|
||
return [...searchedData].sort((a, b) => {
|
||
const aValue = a[sortConfig.key];
|
||
const bValue = b[sortConfig.key];
|
||
|
||
if (aValue < bValue) {
|
||
return sortConfig.direction === 'asc' ? -1 : 1;
|
||
}
|
||
if (aValue > bValue) {
|
||
return sortConfig.direction === 'asc' ? 1 : -1;
|
||
}
|
||
return 0;
|
||
});
|
||
}, [searchedData, sortConfig]);
|
||
|
||
// 分页
|
||
const paginatedData = useMemo(() => {
|
||
if (!pagination) return sortedData;
|
||
|
||
const startIndex = (currentPage - 1) * pageSize;
|
||
return sortedData.slice(startIndex, startIndex + pageSize);
|
||
}, [sortedData, currentPage, pageSize, pagination]);
|
||
|
||
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||
|
||
// 排序处理
|
||
const handleSort = (columnKey: string) => {
|
||
if (!sortable) return;
|
||
|
||
setSortConfig(current => {
|
||
if (current?.key === columnKey) {
|
||
if (current.direction === 'asc') {
|
||
return { key: columnKey, direction: 'desc' };
|
||
} else {
|
||
return null; // 取消排序
|
||
}
|
||
}
|
||
return { key: columnKey, direction: 'asc' };
|
||
});
|
||
};
|
||
|
||
// 选择处理
|
||
const handleRowSelection = (record: T, selected: boolean) => {
|
||
if (!onSelectionChange) return;
|
||
|
||
const recordKey = getRowKey(record, 0);
|
||
if (selected) {
|
||
onSelectionChange([...selectedRows, record]);
|
||
} else {
|
||
onSelectionChange(selectedRows.filter(row => getRowKey(row, 0) !== recordKey));
|
||
}
|
||
};
|
||
|
||
const handleSelectAll = (selected: boolean) => {
|
||
if (!onSelectionChange) return;
|
||
|
||
if (selected) {
|
||
onSelectionChange(paginatedData);
|
||
} else {
|
||
onSelectionChange([]);
|
||
}
|
||
};
|
||
|
||
const isRowSelected = (record: T): boolean => {
|
||
const recordKey = getRowKey(record, 0);
|
||
return selectedRows.some(row => getRowKey(row, 0) === recordKey);
|
||
};
|
||
|
||
const allSelected = paginatedData.length > 0 && paginatedData.every(record => isRowSelected(record));
|
||
const someSelected = selectedRows.length > 0 && !allSelected;
|
||
|
||
return (
|
||
<div className={`bg-white rounded-xl border border-gray-200 overflow-hidden ${className}`}>
|
||
{/* 表格头部工具栏 */}
|
||
{(searchable || filterable || bulkActions.length > 0) && (
|
||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
{/* 搜索 */}
|
||
{searchable && (
|
||
<div className="w-64">
|
||
<SearchInput
|
||
value={searchQuery}
|
||
onChange={setSearchQuery}
|
||
placeholder={searchPlaceholder}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 筛选 */}
|
||
{filterable && (
|
||
<InteractiveButton
|
||
variant="secondary"
|
||
size="sm"
|
||
icon={<Filter size={16} />}
|
||
>
|
||
筛选
|
||
</InteractiveButton>
|
||
)}
|
||
</div>
|
||
|
||
{/* 批量操作 */}
|
||
{bulkActions.length > 0 && selectedRows.length > 0 && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600">
|
||
已选择 {selectedRows.length} 项
|
||
</span>
|
||
{bulkActions.map(action => (
|
||
<InteractiveButton
|
||
key={action.key}
|
||
variant={action.variant || 'secondary'}
|
||
size="sm"
|
||
onClick={() => action.onClick(selectedRows)}
|
||
icon={action.icon}
|
||
>
|
||
{action.label}
|
||
</InteractiveButton>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 表格内容 */}
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
{/* 表头 */}
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
{/* 选择列 */}
|
||
{onSelectionChange && (
|
||
<th className="w-12 px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={allSelected}
|
||
ref={input => {
|
||
if (input) input.indeterminate = someSelected;
|
||
}}
|
||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</th>
|
||
)}
|
||
|
||
{/* 数据列 */}
|
||
{columns.map(column => (
|
||
<th
|
||
key={String(column.key)}
|
||
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider ${
|
||
column.sortable !== false && sortable ? 'cursor-pointer hover:bg-gray-100' : ''
|
||
}`}
|
||
style={{ width: column.width }}
|
||
onClick={() => column.sortable !== false && handleSort(String(column.key))}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span>{column.title}</span>
|
||
{column.sortable !== false && sortable && (
|
||
<div className="flex flex-col">
|
||
<ChevronUp
|
||
size={12}
|
||
className={`${
|
||
sortConfig?.key === column.key && sortConfig.direction === 'asc'
|
||
? 'text-primary-600'
|
||
: 'text-gray-400'
|
||
}`}
|
||
/>
|
||
<ChevronDown
|
||
size={12}
|
||
className={`-mt-1 ${
|
||
sortConfig?.key === column.key && sortConfig.direction === 'desc'
|
||
? 'text-primary-600'
|
||
: 'text-gray-400'
|
||
}`}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</th>
|
||
))}
|
||
|
||
{/* 操作列 */}
|
||
{actions.length > 0 && (
|
||
<th className="w-24 px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
操作
|
||
</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
|
||
{/* 表体 */}
|
||
<tbody className="divide-y divide-gray-200">
|
||
{loading ? (
|
||
// 加载状态
|
||
Array.from({ length: pageSize }).map((_, index) => (
|
||
<tr key={index} className="animate-pulse">
|
||
{onSelectionChange && <td className="px-4 py-4"><div className="w-4 h-4 bg-gray-200 rounded"></div></td>}
|
||
{columns.map(column => (
|
||
<td key={String(column.key)} className="px-4 py-4">
|
||
<div className="h-4 bg-gray-200 rounded"></div>
|
||
</td>
|
||
))}
|
||
{actions.length > 0 && (
|
||
<td className="px-4 py-4">
|
||
<div className="w-8 h-8 bg-gray-200 rounded"></div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))
|
||
) : paginatedData.length === 0 ? (
|
||
// 空状态
|
||
<tr>
|
||
<td
|
||
colSpan={columns.length + (onSelectionChange ? 1 : 0) + (actions.length > 0 ? 1 : 0)}
|
||
className="px-4 py-12 text-center text-gray-500"
|
||
>
|
||
{emptyText}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
// 数据行
|
||
paginatedData.map((record, index) => (
|
||
<tr
|
||
key={getRowKey(record, index)}
|
||
className={`hover:bg-gray-50 transition-colors ${
|
||
onRowClick ? 'cursor-pointer' : ''
|
||
} ${isRowSelected(record) ? 'bg-primary-50' : ''}`}
|
||
onClick={() => onRowClick?.(record)}
|
||
>
|
||
{/* 选择列 */}
|
||
{onSelectionChange && (
|
||
<td className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
|
||
<input
|
||
type="checkbox"
|
||
checked={isRowSelected(record)}
|
||
onChange={(e) => handleRowSelection(record, e.target.checked)}
|
||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
/>
|
||
</td>
|
||
)}
|
||
|
||
{/* 数据列 */}
|
||
{columns.map(column => (
|
||
<td
|
||
key={String(column.key)}
|
||
className={`px-4 py-4 text-sm text-gray-900 ${
|
||
column.align === 'center' ? 'text-center' :
|
||
column.align === 'right' ? 'text-right' : 'text-left'
|
||
}`}
|
||
>
|
||
{column.render
|
||
? column.render(record[column.key as keyof T], record, index)
|
||
: String(record[column.key as keyof T] || '')
|
||
}
|
||
</td>
|
||
))}
|
||
|
||
{/* 操作列 */}
|
||
{actions.length > 0 && (
|
||
<td className="px-4 py-4 text-center" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center justify-center gap-1">
|
||
{actions.map(action => (
|
||
<button
|
||
key={action.key}
|
||
onClick={() => action.onClick(record)}
|
||
disabled={action.disabled?.(record)}
|
||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
title={action.label}
|
||
>
|
||
{action.icon}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 分页 */}
|
||
{pagination && totalPages > 1 && (
|
||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm text-gray-700">
|
||
显示 {(currentPage - 1) * pageSize + 1} 到{' '}
|
||
{Math.min(currentPage * pageSize, sortedData.length)} 项,共 {sortedData.length} 项
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<InteractiveButton
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||
disabled={currentPage === 1}
|
||
>
|
||
上一页
|
||
</InteractiveButton>
|
||
|
||
<span className="text-sm text-gray-700">
|
||
第 {currentPage} 页,共 {totalPages} 页
|
||
</span>
|
||
|
||
<InteractiveButton
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||
disabled={currentPage === totalPages}
|
||
>
|
||
下一页
|
||
</InteractiveButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|