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

374 lines
12 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 { 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>
);
}