|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import {useState, useEffect, useCallback} from 'react'; |
| 4 | +import {useIsMobile} from '@/hooks/use-mobile'; |
| 5 | +import {toast} from 'sonner'; |
| 6 | +import {Button} from '@/components/ui/button'; |
| 7 | +import {Input} from '@/components/ui/input'; |
| 8 | +import {ScrollArea} from '@/components/ui/scroll-area'; |
| 9 | +import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription} from '@/components/animate-ui/radix/dialog'; |
| 10 | +import {EmptyState} from '@/components/common/layout/EmptyState'; |
| 11 | +import {Users, Search, Copy, CheckCircle, AlertCircle, Loader2} from 'lucide-react'; |
| 12 | +import services from '@/lib/services'; |
| 13 | +import {ProjectReceiver} from '@/lib/services/project/types'; |
| 14 | + |
| 15 | +interface ReceiverDialogProps { |
| 16 | + projectId: string; |
| 17 | + projectName: string; |
| 18 | + children?: React.ReactNode; |
| 19 | +} |
| 20 | + |
| 21 | +/** |
| 22 | + * 项目领取人对话框组件 |
| 23 | + */ |
| 24 | +export function ReceiverDialog({ |
| 25 | + projectId, |
| 26 | + projectName, |
| 27 | + children, |
| 28 | +}: ReceiverDialogProps) { |
| 29 | + const isMobile = useIsMobile(); |
| 30 | + const [open, setOpen] = useState(false); |
| 31 | + const [loading, setLoading] = useState(false); |
| 32 | + const [receivers, setReceivers] = useState<ProjectReceiver[]>([]); |
| 33 | + const [filteredReceivers, setFilteredReceivers] = useState<ProjectReceiver[]>([]); |
| 34 | + const [searchKeyword, setSearchKeyword] = useState(''); |
| 35 | + const [error, setError] = useState<string | null>(null); |
| 36 | + const [copiedIndex, setCopiedIndex] = useState<number | null>(null); |
| 37 | + |
| 38 | + /** |
| 39 | + * 获取项目领取人列表 |
| 40 | + */ |
| 41 | + const fetchReceivers = useCallback(async () => { |
| 42 | + setLoading(true); |
| 43 | + setError(null); |
| 44 | + |
| 45 | + try { |
| 46 | + const result = await services.project.getProjectReceiversSafe(projectId); |
| 47 | + |
| 48 | + if (result.success) { |
| 49 | + setReceivers(result.data || []); |
| 50 | + setFilteredReceivers(result.data || []); |
| 51 | + } else { |
| 52 | + setError(result.error || '获取领取人列表失败'); |
| 53 | + } |
| 54 | + } catch { |
| 55 | + setError('获取领取人列表失败'); |
| 56 | + } finally { |
| 57 | + setLoading(false); |
| 58 | + } |
| 59 | + }, [projectId]); |
| 60 | + |
| 61 | + /** |
| 62 | + * 搜索功能 |
| 63 | + */ |
| 64 | + const handleSearch = useCallback((keyword: string) => { |
| 65 | + const filtered = receivers.filter((receiver) => |
| 66 | + receiver.username.toLowerCase().includes(keyword.toLowerCase()) || |
| 67 | + receiver.nickname.toLowerCase().includes(keyword.toLowerCase()) || |
| 68 | + receiver.content.toLowerCase().includes(keyword.toLowerCase()), |
| 69 | + ); |
| 70 | + |
| 71 | + setFilteredReceivers(filtered); |
| 72 | + }, [receivers]); |
| 73 | + |
| 74 | + /** |
| 75 | + * 复制内容到剪贴板 |
| 76 | + */ |
| 77 | + const handleCopy = async (content: string, index: number) => { |
| 78 | + try { |
| 79 | + await navigator.clipboard.writeText(content); |
| 80 | + setCopiedIndex(index); |
| 81 | + toast.success('已复制到剪贴板'); |
| 82 | + |
| 83 | + // 2秒后重置复制状态 |
| 84 | + setTimeout(() => { |
| 85 | + setCopiedIndex(null); |
| 86 | + }, 2000); |
| 87 | + } catch { |
| 88 | + toast.error('复制失败'); |
| 89 | + } |
| 90 | + }; |
| 91 | + |
| 92 | + /** |
| 93 | + * 重试获取数据 |
| 94 | + */ |
| 95 | + const handleRetry = () => { |
| 96 | + fetchReceivers(); |
| 97 | + }; |
| 98 | + |
| 99 | + /** |
| 100 | + * 对话框打开时获取数据 |
| 101 | + */ |
| 102 | + useEffect(() => { |
| 103 | + if (open) { |
| 104 | + fetchReceivers(); |
| 105 | + setSearchKeyword(''); |
| 106 | + } |
| 107 | + }, [open, fetchReceivers]); |
| 108 | + |
| 109 | + /** |
| 110 | + * 搜索关键词变化时过滤数据 |
| 111 | + */ |
| 112 | + useEffect(() => { |
| 113 | + handleSearch(searchKeyword); |
| 114 | + }, [searchKeyword, handleSearch]); |
| 115 | + |
| 116 | + return ( |
| 117 | + <Dialog open={open} onOpenChange={setOpen}> |
| 118 | + <DialogTrigger asChild> |
| 119 | + {children || ( |
| 120 | + <Button size="sm" variant="ghost"> |
| 121 | + <Users className="h-4 w-4" /> |
| 122 | + </Button> |
| 123 | + )} |
| 124 | + </DialogTrigger> |
| 125 | + <DialogContent |
| 126 | + className={`${isMobile ? 'max-w-[95vw] max-h-[85vh]' : 'max-w-3xl max-h-[80vh]'} overflow-hidden`} |
| 127 | + > |
| 128 | + <DialogHeader> |
| 129 | + <DialogTitle className="flex items-center gap-2"> |
| 130 | + 项目领取人 |
| 131 | + </DialogTitle> |
| 132 | + <DialogDescription> |
| 133 | + 查看项目 “{projectName}” 的所有领取人信息 |
| 134 | + </DialogDescription> |
| 135 | + </DialogHeader> |
| 136 | + |
| 137 | + <div className="space-y-3"> |
| 138 | + {/* 搜索栏 */} |
| 139 | + <div className="relative"> |
| 140 | + <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> |
| 141 | + <Input |
| 142 | + placeholder="搜索用户名、昵称或内容..." |
| 143 | + value={searchKeyword} |
| 144 | + onChange={(e) => setSearchKeyword(e.target.value)} |
| 145 | + className="pl-10" |
| 146 | + /> |
| 147 | + </div> |
| 148 | + |
| 149 | + {/* 统计信息 */} |
| 150 | + {!loading && !error && ( |
| 151 | + <div className="flex items-center justify-between text-sm text-muted-foreground"> |
| 152 | + <span> |
| 153 | + 共 {receivers.length} 人领取 |
| 154 | + {searchKeyword && ` · 筛选出 ${filteredReceivers.length} 条结果`} |
| 155 | + </span> |
| 156 | + </div> |
| 157 | + )} |
| 158 | + |
| 159 | + {/* 内容区域 */} |
| 160 | + <div className="min-h-[300px]"> |
| 161 | + {loading ? ( |
| 162 | + <div className="flex items-center justify-center h-[300px]"> |
| 163 | + <div className="flex flex-col items-center gap-2"> |
| 164 | + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> |
| 165 | + <p className="text-sm text-muted-foreground">正在加载领取人列表...</p> |
| 166 | + </div> |
| 167 | + </div> |
| 168 | + ) : error ? ( |
| 169 | + <EmptyState |
| 170 | + icon={AlertCircle} |
| 171 | + title="加载失败" |
| 172 | + description={error} |
| 173 | + className="h-[300px] flex flex-col items-center justify-center" |
| 174 | + > |
| 175 | + <Button onClick={handleRetry} variant="outline" size="sm" className="mt-3"> |
| 176 | + 重试 |
| 177 | + </Button> |
| 178 | + </EmptyState> |
| 179 | + ) : filteredReceivers.length === 0 ? ( |
| 180 | + <EmptyState |
| 181 | + icon={Users} |
| 182 | + title={searchKeyword ? '未找到匹配的领取人' : '暂无领取人'} |
| 183 | + description={searchKeyword ? '尝试调整搜索关键词' : '还没有人领取此项目'} |
| 184 | + className="h-[300px] flex flex-col items-center justify-center" |
| 185 | + /> |
| 186 | + ) : ( |
| 187 | + <ScrollArea className="h-[300px] pr-4"> |
| 188 | + <div className="space-y-2"> |
| 189 | + {filteredReceivers.map((receiver, index) => ( |
| 190 | + <div |
| 191 | + key={`${receiver.username}-${index}`} |
| 192 | + className="p-2 border rounded-md hover:bg-muted/50 transition-colors" |
| 193 | + > |
| 194 | + <div className="flex items-center gap-2"> |
| 195 | + <span className="font-medium text-sm">{receiver.username} ({receiver.nickname})</span> |
| 196 | + <span className="text-xs text-muted-foreground">-</span> |
| 197 | + <span className="text-xs font-mono truncate flex-1 min-w-0">{receiver.content}</span> |
| 198 | + <Button |
| 199 | + variant="secondary" |
| 200 | + size="sm" |
| 201 | + onClick={() => handleCopy(receiver.content, index)} |
| 202 | + className="flex-shrink-0 h-6 w-6 p-0" |
| 203 | + > |
| 204 | + {copiedIndex === index ? ( |
| 205 | + <CheckCircle className="h-3 w-3 text-green-600" /> |
| 206 | + ) : ( |
| 207 | + <Copy className="h-3 w-3" /> |
| 208 | + )} |
| 209 | + </Button> |
| 210 | + </div> |
| 211 | + </div> |
| 212 | + ))} |
| 213 | + </div> |
| 214 | + </ScrollArea> |
| 215 | + )} |
| 216 | + </div> |
| 217 | + </div> |
| 218 | + </DialogContent> |
| 219 | + </Dialog> |
| 220 | + ); |
| 221 | +} |
0 commit comments