diff --git a/apps/desktop/src/components/CardGrid.tsx b/apps/desktop/src/components/CardGrid.tsx new file mode 100644 index 0000000..d9d079f --- /dev/null +++ b/apps/desktop/src/components/CardGrid.tsx @@ -0,0 +1,383 @@ +import React, { useState, useMemo } from 'react'; +import { Grid, List, Search, Filter, SortAsc, SortDesc } from 'lucide-react'; +import { SearchInput } from './InteractiveInput'; +import { InteractiveButton } from './InteractiveButton'; + +export interface CardGridItem { + id: string; + [key: string]: any; +} + +export interface GridAction { + key: string; + label: string; + icon?: React.ReactNode; + onClick: (item: T) => void; + variant?: 'default' | 'primary' | 'danger'; + 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 { + 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[]; + selectedItems?: T[]; + onSelectionChange?: (selectedItems: T[]) => void; + bulkActions?: GridAction[]; +} + +/** + * 增强的卡片网格组件 + * 支持搜索、排序、筛选、视图切换等功能 + */ +export function CardGrid({ + 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 = '', + actions = [], + selectedItems = [], + onSelectionChange, + bulkActions = [], +}: CardGridProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [sortConfig, setSortConfig] = useState(null); + const [activeFilters, setActiveFilters] = useState>({}); + 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 colsMap = { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', + }; + + 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 ( +
+ {/* 工具栏 */} + {(searchable || sortable || filterable || viewModes.length > 1 || bulkActions.length > 0) && ( +
+
+
+ {/* 搜索 */} + {searchable && ( +
+ +
+ )} + + {/* 排序 */} + {sortable && sortOptions.length > 0 && ( +
+ 排序: +
+ {sortOptions.map(option => ( + handleSort(option)} + icon={ + sortConfig?.key === option.key ? ( + sortConfig.direction === 'asc' ? : + ) : undefined + } + > + {option.label} + + ))} +
+
+ )} + + {/* 筛选 */} + {filterable && filterOptions.length > 0 && ( +
+ 筛选: +
+ {filterOptions.map(option => ( + handleFilter(option.key, + activeFilters[option.key] === option.value ? null : option.value + )} + > + {option.label} + + ))} +
+
+ )} +
+ +
+ {/* 批量操作 */} + {bulkActions.length > 0 && selectedItems.length > 0 && ( +
+ + 已选择 {selectedItems.length} 项 + + {bulkActions.map(action => ( + action.onClick(selectedItems)} + icon={action.icon} + > + {action.label} + + ))} +
+ )} + + {/* 全选 */} + {onSelectionChange && sortedItems.length > 0 && ( + + )} + + {/* 视图切换 */} + {viewModes.length > 1 && ( +
+ {viewModes.map(mode => ( + + ))} +
+ )} +
+
+
+ )} + + {/* 内容区域 */} + {loading ? ( +
+
+
+ 加载中... +
+
+ ) : sortedItems.length === 0 ? ( +
+ {emptyComponent || ( +

{emptyText}

+ )} +
+ ) : ( +
+ {sortedItems.map((item, index) => ( +
+ {/* 选择框 */} + {onSelectionChange && ( +
+ handleItemSelection(item, e.target.checked)} + className="rounded border-gray-300 text-primary-600 focus:ring-primary-500 bg-white shadow-sm" + /> +
+ )} + + {/* 卡片内容 */} + {renderCard(item, index)} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/DataTable.tsx b/apps/desktop/src/components/DataTable.tsx new file mode 100644 index 0000000..6a391c0 --- /dev/null +++ b/apps/desktop/src/components/DataTable.tsx @@ -0,0 +1,418 @@ +import React, { useState, useMemo } from 'react'; +import { ChevronUp, ChevronDown, Search, Filter, MoreHorizontal, Eye, Edit, Trash2 } from 'lucide-react'; +import { SearchInput } from './InteractiveInput'; +import { InteractiveButton } from './InteractiveButton'; + +export interface Column { + 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 { + key: string; + label: string; + icon?: React.ReactNode; + onClick: (record: T) => void; + variant?: 'default' | 'primary' | 'danger'; + disabled?: (record: T) => boolean; +} + +interface DataTableProps { + data: T[]; + columns: Column[]; + actions?: TableAction[]; + 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[]; +} + +/** + * 增强的数据表格组件 + * 支持搜索、排序、筛选、分页等功能 + */ +export function DataTable>({ + 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) { + const [searchQuery, setSearchQuery] = useState(''); + const [sortConfig, setSortConfig] = useState<{ + key: string; + direction: 'asc' | 'desc'; + } | null>(null); + const [currentPage, setCurrentPage] = useState(1); + const [filters, setFilters] = useState>({}); + + // 获取行的唯一键 + 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 ( +
+ {/* 表格头部工具栏 */} + {(searchable || filterable || bulkActions.length > 0) && ( +
+
+
+ {/* 搜索 */} + {searchable && ( +
+ +
+ )} + + {/* 筛选 */} + {filterable && ( + } + > + 筛选 + + )} +
+ + {/* 批量操作 */} + {bulkActions.length > 0 && selectedRows.length > 0 && ( +
+ + 已选择 {selectedRows.length} 项 + + {bulkActions.map(action => ( + action.onClick(selectedRows)} + icon={action.icon} + > + {action.label} + + ))} +
+ )} +
+
+ )} + + {/* 表格内容 */} +
+ + {/* 表头 */} + + + {/* 选择列 */} + {onSelectionChange && ( + + )} + + {/* 数据列 */} + {columns.map(column => ( + + ))} + + {/* 操作列 */} + {actions.length > 0 && ( + + )} + + + + {/* 表体 */} + + {loading ? ( + // 加载状态 + Array.from({ length: pageSize }).map((_, index) => ( + + {onSelectionChange && } + {columns.map(column => ( + + ))} + {actions.length > 0 && ( + + )} + + )) + ) : paginatedData.length === 0 ? ( + // 空状态 + + + + ) : ( + // 数据行 + paginatedData.map((record, index) => ( + onRowClick?.(record)} + > + {/* 选择列 */} + {onSelectionChange && ( + + )} + + {/* 数据列 */} + {columns.map(column => ( + + ))} + + {/* 操作列 */} + {actions.length > 0 && ( + + )} + + )) + )} + +
+ { + if (input) input.indeterminate = someSelected; + }} + onChange={(e) => handleSelectAll(e.target.checked)} + className="rounded border-gray-300 text-primary-600 focus:ring-primary-500" + /> + column.sortable !== false && handleSort(String(column.key))} + > +
+ {column.title} + {column.sortable !== false && sortable && ( +
+ + +
+ )} +
+
+ 操作 +
+
+
+
+
0 ? 1 : 0)} + className="px-4 py-12 text-center text-gray-500" + > + {emptyText} +
e.stopPropagation()}> + handleRowSelection(record, e.target.checked)} + className="rounded border-gray-300 text-primary-600 focus:ring-primary-500" + /> + + {column.render + ? column.render(record[column.key as keyof T], record, index) + : String(record[column.key as keyof T] || '') + } + e.stopPropagation()}> +
+ {actions.map(action => ( + + ))} +
+
+
+ + {/* 分页 */} + {pagination && totalPages > 1 && ( +
+
+
+ 显示 {(currentPage - 1) * pageSize + 1} 到{' '} + {Math.min(currentPage * pageSize, sortedData.length)} 项,共 {sortedData.length} 项 +
+
+ setCurrentPage(p => Math.max(1, p - 1))} + disabled={currentPage === 1} + > + 上一页 + + + + 第 {currentPage} 页,共 {totalPages} 页 + + + setCurrentPage(p => Math.min(totalPages, p + 1))} + disabled={currentPage === totalPages} + > + 下一页 + +
+
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/EmptyState.tsx b/apps/desktop/src/components/EmptyState.tsx index 3a9dfc0..129f0cf 100644 --- a/apps/desktop/src/components/EmptyState.tsx +++ b/apps/desktop/src/components/EmptyState.tsx @@ -1,66 +1,324 @@ import React from 'react'; -import { FolderPlus, Sparkles } from 'lucide-react'; +import { FolderPlus, Sparkles, FileText, Users, Video, Image, Music, Search, Inbox, AlertCircle } from 'lucide-react'; +import { InteractiveButton } from './InteractiveButton'; interface EmptyStateProps { + variant?: 'default' | 'search' | 'error' | 'loading' | 'success'; + icon?: React.ReactNode; title: string; description: string; - actionText: string; - onAction: () => void; + actionText?: string; + onAction?: () => void; + secondaryActionText?: string; + onSecondaryAction?: () => void; + illustration?: 'folder' | 'search' | 'users' | 'video' | 'image' | 'music' | 'document' | 'inbox' | 'error'; + size?: 'sm' | 'md' | 'lg'; + showTips?: boolean; + tips?: string[]; + className?: string; } /** - * 空状态组件 - * 遵循现代化设计风格,提供更好的用户体验 + * 增强的空状态组件 + * 支持多种变体、插图和交互方式 */ export const EmptyState: React.FC = ({ + variant = 'default', + icon, title, description, actionText, - onAction + onAction, + secondaryActionText, + onSecondaryAction, + illustration = 'folder', + size = 'md', + showTips = false, + tips = [], + className = '', }) => { + const getIllustrationIcon = () => { + if (icon) return icon; + + const iconMap = { + folder: FolderPlus, + search: Search, + users: Users, + video: Video, + image: Image, + music: Music, + document: FileText, + inbox: Inbox, + error: AlertCircle, + }; + + const IconComponent = iconMap[illustration]; + return ; + }; + + const getIconSize = () => { + switch (size) { + case 'sm': return 48; + case 'lg': return 80; + default: return 64; + } + }; + + const getIconColor = () => { + switch (variant) { + case 'error': return 'text-red-500'; + case 'search': return 'text-blue-500'; + case 'success': return 'text-green-500'; + default: return 'text-primary-600'; + } + }; + + const getVariantStyles = () => { + switch (variant) { + case 'error': + return { + bg: 'from-red-50 to-red-100', + border: 'border-red-200', + glow: 'from-red-100 to-red-200', + }; + case 'search': + return { + bg: 'from-blue-50 to-blue-100', + border: 'border-blue-200', + glow: 'from-blue-100 to-blue-200', + }; + case 'success': + return { + bg: 'from-green-50 to-green-100', + border: 'border-green-200', + glow: 'from-green-100 to-green-200', + }; + default: + return { + bg: 'from-primary-50 to-blue-50', + border: 'border-primary-100', + glow: 'from-primary-100 to-blue-100', + }; + } + }; + + const getSizeClasses = () => { + switch (size) { + case 'sm': + return { + container: 'py-12', + iconContainer: 'p-4 rounded-2xl', + title: 'text-lg', + description: 'text-sm', + spacing: 'space-y-3', + }; + case 'lg': + return { + container: 'py-24', + iconContainer: 'p-8 rounded-3xl', + title: 'text-3xl', + description: 'text-xl', + spacing: 'space-y-6', + }; + default: + return { + container: 'py-20', + iconContainer: 'p-6 rounded-3xl', + title: 'text-2xl', + description: 'text-lg', + spacing: 'space-y-4', + }; + } + }; + + const styles = getVariantStyles(); + const sizeClasses = getSizeClasses(); + return ( -
+
{/* 图标区域 */}
{/* 背景装饰 */} -
+
{/* 主图标 */} -
- +
+ {getIllustrationIcon()} {/* 装饰性小图标 */} -
- -
+ {variant === 'default' && ( +
+ +
+ )}
{/* 文本内容 */} -
-

+
+

{title}

-

+

{description}

{/* 操作按钮 */} -
- -
+ {(actionText || secondaryActionText) && ( +
+ {actionText && onAction && ( + : undefined} + className="shadow-glow animate-pulse-slow" + > + {actionText} + + )} + + {secondaryActionText && onSecondaryAction && ( + + {secondaryActionText} + + )} +
+ )} {/* 提示信息 */} -
-

💡 提示:您可以通过拖拽文件夹到此处快速创建项目

-
+ {showTips && tips.length > 0 && ( +
+ {tips.map((tip, index) => ( +

+ 💡 + {tip} +

+ ))} +
+ )}

); }; + +/** + * 预设的空状态组件 + */ + +// 项目列表空状态 +export const EmptyProjectList: React.FC<{ onCreateProject: () => void }> = ({ onCreateProject }) => ( + +); + +// 模特列表空状态 +export const EmptyModelList: React.FC<{ onCreateModel: () => void }> = ({ onCreateModel }) => ( + +); + +// 素材列表空状态 +export const EmptyMaterialList: React.FC<{ onImportMaterial: () => void }> = ({ onImportMaterial }) => ( + +); + +// 模板列表空状态 +export const EmptyTemplateList: React.FC<{ onImportTemplate: () => void }> = ({ onImportTemplate }) => ( + +); + +// 搜索结果空状态 +export const EmptySearchResult: React.FC<{ query: string; onClearSearch?: () => void }> = ({ + query, + onClearSearch +}) => ( + +); + +// 错误状态 +export const ErrorState: React.FC<{ + title?: string; + description?: string; + onRetry?: () => void; + onGoBack?: () => void; +}> = ({ + title = "出现了一些问题", + description = "请稍后重试,或联系技术支持", + onRetry, + onGoBack +}) => ( + +); + +// 加载状态 +export const LoadingState: React.FC<{ message?: string }> = ({ + message = "正在加载..." +}) => ( + +); diff --git a/apps/desktop/src/components/InteractiveButton.tsx b/apps/desktop/src/components/InteractiveButton.tsx new file mode 100644 index 0000000..5299051 --- /dev/null +++ b/apps/desktop/src/components/InteractiveButton.tsx @@ -0,0 +1,266 @@ +import React, { useState, useRef } from 'react'; +import { Loader2 } from 'lucide-react'; + +interface InteractiveButtonProps { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void | Promise; + variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'ghost' | 'outline'; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + disabled?: boolean; + loading?: boolean; + icon?: React.ReactNode; + iconPosition?: 'left' | 'right'; + fullWidth?: boolean; + ripple?: boolean; + haptic?: boolean; + className?: string; + type?: 'button' | 'submit' | 'reset'; +} + +/** + * 增强的交互按钮组件 + * 提供丰富的视觉反馈和微交互效果 + */ +export const InteractiveButton: React.FC = ({ + children, + onClick, + variant = 'primary', + size = 'md', + disabled = false, + loading = false, + icon, + iconPosition = 'left', + fullWidth = false, + ripple = true, + haptic = true, + className = '', + type = 'button', +}) => { + const [isPressed, setIsPressed] = useState(false); + const [ripples, setRipples] = useState>([]); + const buttonRef = useRef(null); + const rippleIdRef = useRef(0); + + const getVariantClasses = () => { + const variants = { + primary: 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white shadow-sm hover:shadow-md focus:ring-primary-500', + secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-900 shadow-sm hover:shadow focus:ring-gray-500', + danger: 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white shadow-sm hover:shadow-md focus:ring-red-500', + success: 'bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white shadow-sm hover:shadow-md focus:ring-green-500', + ghost: 'hover:bg-gray-100 text-gray-700 hover:text-gray-900 focus:ring-gray-500', + outline: 'border border-gray-300 hover:border-gray-400 bg-white hover:bg-gray-50 text-gray-700 hover:text-gray-900 shadow-sm focus:ring-gray-500', + }; + return variants[variant]; + }; + + const getSizeClasses = () => { + const sizes = { + xs: 'px-2 py-1 text-xs', + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-5 py-2.5 text-base', + xl: 'px-6 py-3 text-lg', + }; + return sizes[size]; + }; + + const getIconSize = () => { + const iconSizes = { + xs: 'w-3 h-3', + sm: 'w-4 h-4', + md: 'w-4 h-4', + lg: 'w-5 h-5', + xl: 'w-6 h-6', + }; + return iconSizes[size]; + }; + + const handleClick = async (e: React.MouseEvent) => { + if (disabled || loading) return; + + // 添加按压效果 + setIsPressed(true); + setTimeout(() => setIsPressed(false), 150); + + // 添加涟漪效果 + if (ripple && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const newRipple = { id: rippleIdRef.current++, x, y }; + + setRipples(prev => [...prev, newRipple]); + + // 移除涟漪效果 + setTimeout(() => { + setRipples(prev => prev.filter(r => r.id !== newRipple.id)); + }, 600); + } + + // 触觉反馈(如果支持) + if (haptic && 'vibrate' in navigator) { + navigator.vibrate(10); + } + + // 执行点击处理 + if (onClick) { + await onClick(e); + } + }; + + const baseClasses = ` + relative overflow-hidden + inline-flex items-center justify-center + font-medium rounded-lg + transition-all duration-200 ease-out + focus:outline-none focus:ring-2 focus:ring-offset-2 + disabled:opacity-50 disabled:cursor-not-allowed + transform hover:scale-105 active:scale-95 + ${isPressed ? 'animate-button-press' : ''} + ${fullWidth ? 'w-full' : ''} + `; + + return ( + + ); +}; + +/** + * 浮动操作按钮 + */ +interface FloatingActionButtonProps { + onClick?: () => void; + icon: React.ReactNode; + tooltip?: string; + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; + className?: string; +} + +export const FloatingActionButton: React.FC = ({ + onClick, + icon, + tooltip, + variant = 'primary', + size = 'md', + position = 'bottom-right', + className = '', +}) => { + const [showTooltip, setShowTooltip] = useState(false); + + const getVariantClasses = () => { + const variants = { + primary: 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white shadow-lg hover:shadow-xl', + secondary: 'bg-white hover:bg-gray-50 text-gray-700 shadow-lg hover:shadow-xl border border-gray-200', + danger: 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white shadow-lg hover:shadow-xl', + }; + return variants[variant]; + }; + + const getSizeClasses = () => { + const sizes = { + sm: 'w-12 h-12', + md: 'w-14 h-14', + lg: 'w-16 h-16', + }; + return sizes[size]; + }; + + const getPositionClasses = () => { + const positions = { + 'bottom-right': 'fixed bottom-6 right-6', + 'bottom-left': 'fixed bottom-6 left-6', + 'top-right': 'fixed top-6 right-6', + 'top-left': 'fixed top-6 left-6', + }; + return positions[position]; + }; + + const getIconSize = () => { + const iconSizes = { + sm: 'w-5 h-5', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + return iconSizes[size]; + }; + + return ( +
+ + + {/* 工具提示 */} + {tooltip && showTooltip && ( +
+ {tooltip} +
+
+ )} +
+ ); +}; diff --git a/apps/desktop/src/components/InteractiveInput.tsx b/apps/desktop/src/components/InteractiveInput.tsx new file mode 100644 index 0000000..594bf60 --- /dev/null +++ b/apps/desktop/src/components/InteractiveInput.tsx @@ -0,0 +1,431 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Eye, EyeOff, AlertCircle, CheckCircle, Search, X } from 'lucide-react'; + +interface InteractiveInputProps { + type?: 'text' | 'email' | 'password' | 'search' | 'number' | 'tel' | 'url'; + value?: string; + onChange?: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; + onEnter?: () => void; + placeholder?: string; + label?: string; + error?: string; + success?: string; + hint?: string; + required?: boolean; + disabled?: boolean; + loading?: boolean; + icon?: React.ReactNode; + iconPosition?: 'left' | 'right'; + clearable?: boolean; + autoFocus?: boolean; + maxLength?: number; + className?: string; + inputClassName?: string; +} + +/** + * 增强的交互输入组件 + * 提供丰富的视觉反馈和状态指示 + */ +export const InteractiveInput: React.FC = ({ + type = 'text', + value = '', + onChange, + onFocus, + onBlur, + onEnter, + placeholder, + label, + error, + success, + hint, + required = false, + disabled = false, + loading = false, + icon, + iconPosition = 'left', + clearable = false, + autoFocus = false, + maxLength, + className = '', + inputClassName = '', +}) => { + const [isFocused, setIsFocused] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [internalValue, setInternalValue] = useState(value); + const inputRef = useRef(null); + + useEffect(() => { + setInternalValue(value); + }, [value]); + + useEffect(() => { + if (autoFocus && inputRef.current) { + inputRef.current.focus(); + } + }, [autoFocus]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInternalValue(newValue); + onChange?.(newValue); + }; + + const handleFocus = () => { + setIsFocused(true); + onFocus?.(); + }; + + const handleBlur = () => { + setIsFocused(false); + onBlur?.(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onEnter?.(); + } + }; + + const handleClear = () => { + setInternalValue(''); + onChange?.(''); + inputRef.current?.focus(); + }; + + const getInputType = () => { + if (type === 'password') { + return showPassword ? 'text' : 'password'; + } + return type; + }; + + const getStatusColor = () => { + if (error) return 'border-red-300 focus:border-red-500 focus:ring-red-500'; + if (success) return 'border-green-300 focus:border-green-500 focus:ring-green-500'; + return 'border-gray-300 focus:border-primary-500 focus:ring-primary-500'; + }; + + const getStatusIcon = () => { + if (loading) { + return
; + } + if (error) { + return ; + } + if (success) { + return ; + } + return null; + }; + + const showClearButton = clearable && internalValue && !disabled && !loading; + const showPasswordToggle = type === 'password' && !disabled; + + return ( +
+ {/* 标签 */} + {label && ( + + )} + + {/* 输入容器 */} +
+ {/* 左侧图标 */} + {icon && iconPosition === 'left' && ( +
+ {icon} +
+ )} + + {/* 搜索图标(特殊处理) */} + {type === 'search' && !icon && ( +
+ +
+ )} + + {/* 输入框 */} + + + {/* 右侧图标区域 */} +
+ {/* 清除按钮 */} + {showClearButton && ( + + )} + + {/* 密码显示切换 */} + {showPasswordToggle && ( + + )} + + {/* 状态图标 */} + {getStatusIcon()} + + {/* 右侧图标 */} + {icon && iconPosition === 'right' && ( + {icon} + )} +
+ + {/* 焦点指示器 */} + {isFocused && ( +
+ )} +
+ + {/* 底部信息 */} +
+
+ {/* 错误信息 */} + {error && ( +

+ + {error} +

+ )} + + {/* 成功信息 */} + {success && !error && ( +

+ + {success} +

+ )} + + {/* 提示信息 */} + {hint && !error && !success && ( +

{hint}

+ )} +
+ + {/* 字符计数 */} + {maxLength && ( +

maxLength * 0.8 ? 'text-orange-500' : 'text-gray-400'}`}> + {internalValue.length}/{maxLength} +

+ )} +
+
+ ); +}; + +/** + * 搜索输入组件 + */ +interface SearchInputProps { + value?: string; + onChange?: (value: string) => void; + onSearch?: (value: string) => void; + placeholder?: string; + loading?: boolean; + className?: string; +} + +export const SearchInput: React.FC = ({ + value = '', + onChange, + onSearch, + placeholder = '搜索...', + loading = false, + className = '', +}) => { + return ( + onSearch?.(value)} + placeholder={placeholder} + loading={loading} + clearable + className={className} + /> + ); +}; + +/** + * 交互式文本区域组件 + */ +interface InteractiveTextareaProps { + value?: string; + onChange?: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + label?: string; + error?: string; + success?: string; + hint?: string; + required?: boolean; + disabled?: boolean; + rows?: number; + maxLength?: number; + className?: string; + textareaClassName?: string; +} + +export const InteractiveTextarea: React.FC = ({ + value = '', + onChange, + onFocus, + onBlur, + placeholder, + label, + error, + success, + hint, + required = false, + disabled = false, + rows = 4, + maxLength, + className = '', + textareaClassName = '', +}) => { + const [isFocused, setIsFocused] = useState(false); + const [internalValue, setInternalValue] = useState(value); + + useEffect(() => { + setInternalValue(value); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInternalValue(newValue); + onChange?.(newValue); + }; + + const handleFocus = () => { + setIsFocused(true); + onFocus?.(); + }; + + const handleBlur = () => { + setIsFocused(false); + onBlur?.(); + }; + + const getStatusColor = () => { + if (error) return 'border-red-300 focus:border-red-500 focus:ring-red-500'; + if (success) return 'border-green-300 focus:border-green-500 focus:ring-green-500'; + return 'border-gray-300 focus:border-primary-500 focus:ring-primary-500'; + }; + + return ( +
+ {/* 标签 */} + {label && ( + + )} + + {/* 文本区域容器 */} +
+