374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import { Grid, List, SortAsc, SortDesc } from 'lucide-react';
|
||
import { SearchInput } from './InteractiveInput';
|
||
import { InteractiveButton } from './InteractiveButton';
|
||
|
||
export interface CardGridItem {
|
||
id: string;
|
||
[key: string]: any;
|
||
}
|
||
|
||
export interface GridAction<T> {
|
||
key: string;
|
||
label: string;
|
||
icon?: React.ReactNode;
|
||
onClick: (item: T) => void;
|
||
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'ghost' | 'outline';
|
||
disabled?: (item: T) => boolean;
|
||
}
|
||
|
||
export interface SortOption {
|
||
key: string;
|
||
label: string;
|
||
direction?: 'asc' | 'desc';
|
||
}
|
||
|
||
export interface FilterOption {
|
||
key: string;
|
||
label: string;
|
||
value: any;
|
||
}
|
||
|
||
interface CardGridProps<T extends CardGridItem> {
|
||
items: T[];
|
||
renderCard: (item: T, index: number) => React.ReactNode;
|
||
loading?: boolean;
|
||
searchable?: boolean;
|
||
searchKeys?: (keyof T)[];
|
||
searchPlaceholder?: string;
|
||
sortable?: boolean;
|
||
sortOptions?: SortOption[];
|
||
filterable?: boolean;
|
||
filterOptions?: FilterOption[];
|
||
viewModes?: ('grid' | 'list')[];
|
||
defaultViewMode?: 'grid' | 'list';
|
||
gridCols?: {
|
||
sm?: number;
|
||
md?: number;
|
||
lg?: number;
|
||
xl?: number;
|
||
'2xl'?: number;
|
||
};
|
||
gap?: number;
|
||
emptyText?: string;
|
||
emptyComponent?: React.ReactNode;
|
||
className?: string;
|
||
actions?: GridAction<T>[];
|
||
selectedItems?: T[];
|
||
onSelectionChange?: (selectedItems: T[]) => void;
|
||
bulkActions?: GridAction<T[]>[];
|
||
}
|
||
|
||
/**
|
||
* 增强的卡片网格组件
|
||
* 支持搜索、排序、筛选、视图切换等功能
|
||
*/
|
||
export function CardGrid<T extends CardGridItem>({
|
||
items,
|
||
renderCard,
|
||
loading = false,
|
||
searchable = true,
|
||
searchKeys = [],
|
||
searchPlaceholder = '搜索...',
|
||
sortable = true,
|
||
sortOptions = [],
|
||
filterable = false,
|
||
filterOptions = [],
|
||
viewModes = ['grid', 'list'],
|
||
defaultViewMode = 'grid',
|
||
gridCols = {
|
||
sm: 1,
|
||
md: 2,
|
||
lg: 3,
|
||
xl: 4,
|
||
'2xl': 5,
|
||
},
|
||
gap = 6,
|
||
emptyText = '暂无数据',
|
||
emptyComponent,
|
||
className = '',
|
||
selectedItems = [],
|
||
onSelectionChange,
|
||
bulkActions = [],
|
||
}: CardGridProps<T>) {
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [sortConfig, setSortConfig] = useState<SortOption | null>(null);
|
||
const [activeFilters, setActiveFilters] = useState<Record<string, any>>({});
|
||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(defaultViewMode);
|
||
|
||
// 搜索过滤
|
||
const searchedItems = useMemo(() => {
|
||
if (!searchQuery.trim()) return items;
|
||
|
||
const searchLower = searchQuery.toLowerCase();
|
||
return items.filter(item => {
|
||
if (searchKeys.length > 0) {
|
||
return searchKeys.some(key => {
|
||
const value = item[key];
|
||
return String(value).toLowerCase().includes(searchLower);
|
||
});
|
||
} else {
|
||
return Object.values(item).some(value =>
|
||
String(value).toLowerCase().includes(searchLower)
|
||
);
|
||
}
|
||
});
|
||
}, [items, searchQuery, searchKeys]);
|
||
|
||
// 筛选
|
||
const filteredItems = useMemo(() => {
|
||
return searchedItems.filter(item => {
|
||
return Object.entries(activeFilters).every(([key, value]) => {
|
||
if (value === null || value === undefined || value === '') return true;
|
||
return item[key] === value;
|
||
});
|
||
});
|
||
}, [searchedItems, activeFilters]);
|
||
|
||
// 排序
|
||
const sortedItems = useMemo(() => {
|
||
if (!sortConfig) return filteredItems;
|
||
|
||
return [...filteredItems].sort((a, b) => {
|
||
const aValue = a[sortConfig.key];
|
||
const bValue = b[sortConfig.key];
|
||
const direction = sortConfig.direction || 'asc';
|
||
|
||
if (aValue < bValue) {
|
||
return direction === 'asc' ? -1 : 1;
|
||
}
|
||
if (aValue > bValue) {
|
||
return direction === 'asc' ? 1 : -1;
|
||
}
|
||
return 0;
|
||
});
|
||
}, [filteredItems, sortConfig]);
|
||
|
||
// 网格列数类名
|
||
const getGridColsClass = () => {
|
||
const classes = [];
|
||
if (gridCols.sm) classes.push(`grid-cols-${gridCols.sm}`);
|
||
if (gridCols.md) classes.push(`md:grid-cols-${gridCols.md}`);
|
||
if (gridCols.lg) classes.push(`lg:grid-cols-${gridCols.lg}`);
|
||
if (gridCols.xl) classes.push(`xl:grid-cols-${gridCols.xl}`);
|
||
if (gridCols['2xl']) classes.push(`2xl:grid-cols-${gridCols['2xl']}`);
|
||
|
||
return classes.join(' ') || 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
|
||
};
|
||
|
||
// 处理排序
|
||
const handleSort = (option: SortOption) => {
|
||
setSortConfig(current => {
|
||
if (current?.key === option.key) {
|
||
const newDirection = current.direction === 'asc' ? 'desc' : 'asc';
|
||
return { ...option, direction: newDirection };
|
||
}
|
||
return { ...option, direction: option.direction || 'asc' };
|
||
});
|
||
};
|
||
|
||
// 处理筛选
|
||
const handleFilter = (key: string, value: any) => {
|
||
setActiveFilters(current => ({
|
||
...current,
|
||
[key]: value,
|
||
}));
|
||
};
|
||
|
||
// 选择处理
|
||
const isItemSelected = (item: T): boolean => {
|
||
return selectedItems.some(selected => selected.id === item.id);
|
||
};
|
||
|
||
const handleItemSelection = (item: T, selected: boolean) => {
|
||
if (!onSelectionChange) return;
|
||
|
||
if (selected) {
|
||
onSelectionChange([...selectedItems, item]);
|
||
} else {
|
||
onSelectionChange(selectedItems.filter(selected => selected.id !== item.id));
|
||
}
|
||
};
|
||
|
||
const handleSelectAll = (selected: boolean) => {
|
||
if (!onSelectionChange) return;
|
||
|
||
if (selected) {
|
||
onSelectionChange(sortedItems);
|
||
} else {
|
||
onSelectionChange([]);
|
||
}
|
||
};
|
||
|
||
const allSelected = sortedItems.length > 0 && sortedItems.every(item => isItemSelected(item));
|
||
const someSelected = selectedItems.length > 0 && !allSelected;
|
||
|
||
return (
|
||
<div className={`space-y-4 ${className}`}>
|
||
{/* 工具栏 */}
|
||
{(searchable || sortable || filterable || viewModes.length > 1 || bulkActions.length > 0) && (
|
||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||
{/* 搜索 */}
|
||
{searchable && (
|
||
<div className="w-full sm:w-64">
|
||
<SearchInput
|
||
value={searchQuery}
|
||
onChange={setSearchQuery}
|
||
placeholder={searchPlaceholder}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 排序 */}
|
||
{sortable && sortOptions.length > 0 && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600 whitespace-nowrap">排序:</span>
|
||
<div className="flex gap-1">
|
||
{sortOptions.map(option => (
|
||
<InteractiveButton
|
||
key={option.key}
|
||
variant={sortConfig?.key === option.key ? 'primary' : 'secondary'}
|
||
size="sm"
|
||
onClick={() => handleSort(option)}
|
||
icon={
|
||
sortConfig?.key === option.key ? (
|
||
sortConfig.direction === 'asc' ? <SortAsc size={14} /> : <SortDesc size={14} />
|
||
) : undefined
|
||
}
|
||
>
|
||
{option.label}
|
||
</InteractiveButton>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 筛选 */}
|
||
{filterable && filterOptions.length > 0 && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600 whitespace-nowrap">筛选:</span>
|
||
<div className="flex gap-1">
|
||
{filterOptions.map(option => (
|
||
<InteractiveButton
|
||
key={option.key}
|
||
variant={activeFilters[option.key] === option.value ? 'primary' : 'secondary'}
|
||
size="sm"
|
||
onClick={() => handleFilter(option.key,
|
||
activeFilters[option.key] === option.value ? null : option.value
|
||
)}
|
||
>
|
||
{option.label}
|
||
</InteractiveButton>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{/* 批量操作 */}
|
||
{bulkActions.length > 0 && selectedItems.length > 0 && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600">
|
||
已选择 {selectedItems.length} 项
|
||
</span>
|
||
{bulkActions.map(action => (
|
||
<InteractiveButton
|
||
key={action.key}
|
||
variant={action.variant || 'secondary'}
|
||
size="sm"
|
||
onClick={() => action.onClick(selectedItems)}
|
||
icon={action.icon}
|
||
>
|
||
{action.label}
|
||
</InteractiveButton>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 全选 */}
|
||
{onSelectionChange && sortedItems.length > 0 && (
|
||
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||
<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"
|
||
/>
|
||
全选
|
||
</label>
|
||
)}
|
||
|
||
{/* 视图切换 */}
|
||
{viewModes.length > 1 && (
|
||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||
{viewModes.map(mode => (
|
||
<button
|
||
key={mode}
|
||
onClick={() => setViewMode(mode)}
|
||
className={`p-2 transition-colors ${
|
||
viewMode === mode
|
||
? 'bg-primary-600 text-white'
|
||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||
}`}
|
||
title={mode === 'grid' ? '网格视图' : '列表视图'}
|
||
>
|
||
{mode === 'grid' ? <Grid size={16} /> : <List size={16} />}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 内容区域 */}
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="inline-flex items-center gap-3">
|
||
<div className="w-6 h-6 border-2 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||
<span className="text-gray-600">加载中...</span>
|
||
</div>
|
||
</div>
|
||
) : sortedItems.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
{emptyComponent || (
|
||
<p className="text-gray-500">{emptyText}</p>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className={
|
||
viewMode === 'grid'
|
||
? `grid ${getGridColsClass()} gap-${gap}`
|
||
: 'space-y-4'
|
||
}>
|
||
{sortedItems.map((item, index) => (
|
||
<div key={item.id} className="relative">
|
||
{/* 选择框 */}
|
||
{onSelectionChange && (
|
||
<div className="absolute top-2 left-2 z-10">
|
||
<input
|
||
type="checkbox"
|
||
checked={isItemSelected(item)}
|
||
onChange={(e) => handleItemSelection(item, e.target.checked)}
|
||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500 bg-white shadow-sm"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 卡片内容 */}
|
||
{renderCard(item, index)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|