mixvideo-v2/apps/desktop/src/components/DataTable.tsx

417 lines
14 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.

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>
);
}