GroupSelector.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import React from 'react';
  2. import { KnowledgeGroup } from '../types';
  3. import { Check, ChevronDown, Search } from 'lucide-react';
  4. interface GroupSelectorProps {
  5. groups: KnowledgeGroup[];
  6. selectedGroups: string[];
  7. onSelectionChange: (groupIds: string[]) => void;
  8. showSelectAll?: boolean;
  9. placeholder?: string;
  10. minimal?: boolean;
  11. direction?: 'up' | 'bottom'; // Added direction prop
  12. }
  13. export const GroupSelector: React.FC<GroupSelectorProps> = ({
  14. groups,
  15. selectedGroups,
  16. onSelectionChange,
  17. showSelectAll = true,
  18. placeholder = '选择分组范围',
  19. minimal = false,
  20. direction = 'bottom'
  21. }) => {
  22. const [isOpen, setIsOpen] = React.useState(false);
  23. const [dropdownStyle, setDropdownStyle] = React.useState<React.CSSProperties>({});
  24. const [searchTerm, setSearchTerm] = React.useState('');
  25. const containerRef = React.useRef<HTMLDivElement>(null);
  26. const searchInputRef = React.useRef<HTMLInputElement>(null);
  27. React.useEffect(() => {
  28. if (isOpen && containerRef.current) {
  29. const button = containerRef.current.querySelector('button') as HTMLElement;
  30. if (button) {
  31. const rect = button.getBoundingClientRect();
  32. // Calculate style based on direction
  33. const style: React.CSSProperties = {
  34. left: rect.left,
  35. width: Math.max(rect.width, 240), // Min width for readability
  36. };
  37. if (direction === 'up') {
  38. style.bottom = window.innerHeight - rect.top + 4;
  39. style.maxHeight = '320px'; // Increased height for search + list
  40. } else {
  41. style.top = rect.bottom + 4;
  42. style.maxHeight = '320px';
  43. }
  44. setDropdownStyle(style);
  45. // Auto-focus search input when opening
  46. setTimeout(() => searchInputRef.current?.focus(), 50);
  47. }
  48. } else {
  49. setSearchTerm(''); // Reset search on close
  50. }
  51. }, [isOpen, direction]);
  52. const filteredGroups = groups.filter(g =>
  53. g.name.toLowerCase().includes(searchTerm.toLowerCase())
  54. );
  55. const isAllSelected = selectedGroups.length === 0;
  56. // Optimized display logic
  57. let selectedGroupNames = '';
  58. if (selectedGroups.length === 0) {
  59. selectedGroupNames = '全部分组';
  60. } else if (selectedGroups.length <= 2) {
  61. selectedGroupNames = selectedGroups.map(id => groups.find(g => g.id === id)?.name).filter(Boolean).join(', ');
  62. } else {
  63. selectedGroupNames = `已选 ${selectedGroups.length} 个分组`;
  64. }
  65. const handleToggleGroup = (groupId: string) => {
  66. if (selectedGroups.includes(groupId)) {
  67. onSelectionChange(selectedGroups.filter(id => id !== groupId));
  68. } else {
  69. onSelectionChange([...selectedGroups, groupId]);
  70. }
  71. };
  72. const handleSelectAll = () => {
  73. onSelectionChange([]);
  74. };
  75. return (
  76. <div className={`relative ${minimal ? '' : 'w-full'} `} ref={containerRef}>
  77. <button
  78. onClick={() => setIsOpen(!isOpen)}
  79. className={`flex items - center justify - between px - 3 py - 2 bg - white border border - gray - 300 rounded - md text - left focus: outline - none focus: ring - 2 focus: ring - blue - 500 ${minimal ? 'h-9 text-sm min-w-[120px]' : 'w-full'} `}
  80. title={selectedGroups.length > 2 ? selectedGroups.map(id => groups.find(g => g.id === id)?.name).join(', ') : undefined}
  81. >
  82. <span className={`truncate mr - 2 ${selectedGroups.length === 0 ? 'text-gray-500' : 'text-gray-900'} `} style={{ maxWidth: minimal ? '120px' : 'none' }}>
  83. {selectedGroupNames || placeholder}
  84. </span>
  85. <ChevronDown size={14} className={`text - gray - 400 text - xs transition - transform flex - shrink - 0 ${isOpen ? 'rotate-180' : ''} `} />
  86. </button>
  87. {isOpen && (
  88. <>
  89. <div
  90. className="fixed inset-0 z-[9998]"
  91. onClick={() => setIsOpen(false)}
  92. />
  93. <div className="fixed bg-white border border-gray-300 rounded-lg shadow-xl z-[9999] flex flex-col animate-in fade-in zoom-in-95 duration-100"
  94. style={dropdownStyle}>
  95. {/* Search Box */}
  96. <div className="p-2 border-b border-gray-100 shrink-0">
  97. <div className="relative">
  98. <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
  99. <input
  100. ref={searchInputRef}
  101. type="text"
  102. placeholder="搜索分组..."
  103. value={searchTerm}
  104. onChange={(e) => setSearchTerm(e.target.value)}
  105. className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-gray-50"
  106. onClick={(e) => e.stopPropagation()}
  107. />
  108. </div>
  109. </div>
  110. <div className="overflow-y-auto flex-1 p-0">
  111. {!searchTerm && showSelectAll && (
  112. <div
  113. onClick={handleSelectAll}
  114. className={`flex items - center px - 3 py - 2 cursor - pointer hover: bg - gray - 50 border - b border - gray - 100 ${isAllSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
  115. } `}
  116. >
  117. <div className={`w - 4 h - 4 mr - 3 border rounded flex items - center justify - center ${isAllSelected ? 'bg-blue-600 border-blue-600' : 'border-gray-300'
  118. } `}>
  119. {isAllSelected && <Check size={12} className="text-white" />}
  120. </div>
  121. <span className="font-medium text-sm">全部分组</span>
  122. </div>
  123. )}
  124. <div className="py-1">
  125. {filteredGroups.map((group) => {
  126. const isSelected = selectedGroups.includes(group.id);
  127. return (
  128. <div
  129. key={group.id}
  130. onClick={() => handleToggleGroup(group.id)}
  131. className={`flex items - center px - 3 py - 2 cursor - pointer hover: bg - gray - 50 transition - colors ${isSelected ? 'bg-blue-50' : ''
  132. } `}
  133. >
  134. <div className={`w - 4 h - 4 mr - 3 border rounded flex items - center justify - center flex - shrink - 0 ${isSelected ? 'bg-blue-600 border-blue-600' : 'border-gray-300'
  135. } `}>
  136. {isSelected && <Check size={12} className="text-white" />}
  137. </div>
  138. <div
  139. className="w-2.5 h-2.5 rounded-full mr-2 flex-shrink-0"
  140. style={{ backgroundColor: group.color }}
  141. />
  142. <div className="flex-1 min-w-0">
  143. <div className={`text - sm truncate ${isSelected ? 'text-blue-700 font-medium' : 'text-gray-700'} `}>
  144. {group.name}
  145. </div>
  146. <div className="text-[10px] text-gray-400">
  147. {group.fileCount} 文件
  148. </div>
  149. </div>
  150. </div>
  151. );
  152. })}
  153. {filteredGroups.length === 0 && (
  154. <div className="px-3 py-6 text-center text-gray-400 text-xs">
  155. {searchTerm ? '未找到相关分组' : '暂无分组'}
  156. </div>
  157. )}
  158. </div>
  159. </div>
  160. </div>
  161. </>
  162. )}
  163. </div>
  164. );
  165. };