2
0

AvatarSelector.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import React, { useState } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { Button } from '@/client/components/ui/button';
  4. import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
  5. import { Card, CardContent } from '@/client/components/ui/card';
  6. import { Badge } from '@/client/components/ui/badge';
  7. import { toast } from 'sonner';
  8. import { fileClient } from '@/client/api';
  9. import type { FileType } from '@/server/modules/files/file.schema';
  10. import MinioUploader from '@/client/admin-shadcn/components/MinioUploader';
  11. import { Check, Upload, Eye, X } from 'lucide-react';
  12. import { Avatar, AvatarFallback, AvatarImage } from '@/client/components/ui/avatar';
  13. import { cn } from '@/client/lib/utils';
  14. interface AvatarSelectorProps {
  15. value?: number;
  16. onChange: (fileId: number) => void;
  17. accept?: string;
  18. maxSize?: number;
  19. uploadPath?: string;
  20. uploadButtonText?: string;
  21. previewSize?: 'small' | 'medium' | 'large';
  22. showPreview?: boolean;
  23. placeholder?: string;
  24. }
  25. const AvatarSelector: React.FC<AvatarSelectorProps> = ({
  26. value,
  27. onChange,
  28. accept = 'image/*',
  29. maxSize = 2,
  30. uploadPath = '/avatars',
  31. uploadButtonText = '上传头像',
  32. previewSize = 'medium',
  33. showPreview = true,
  34. placeholder = '选择头像',
  35. }) => {
  36. const [isOpen, setIsOpen] = useState(false);
  37. const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
  38. // 获取当前选中的文件详情
  39. const { data: currentFile } = useQuery({
  40. queryKey: ['file-detail', value],
  41. queryFn: async () => {
  42. if (!value) return null;
  43. const response = await fileClient[':id']['$get']({ param: { id: value.toString() } });
  44. if (response.status !== 200) throw new Error('获取文件详情失败');
  45. return response.json();
  46. },
  47. enabled: !!value,
  48. });
  49. // 获取头像列表
  50. const { data: filesData, isLoading } = useQuery({
  51. queryKey: ['avatars-for-selection'] as const,
  52. queryFn: async () => {
  53. const response = await fileClient.$get({
  54. query: {
  55. page: 1,
  56. pageSize: 50,
  57. keyword: 'image'
  58. }
  59. });
  60. if (response.status !== 200) throw new Error('获取头像列表失败');
  61. return response.json();
  62. },
  63. enabled: isOpen,
  64. });
  65. const avatars = filesData?.data?.filter((f: any) => f?.type?.startsWith('image/')) || [];
  66. const handleSelectAvatar = (file: FileType) => {
  67. setSelectedFile(file);
  68. };
  69. const handleConfirm = () => {
  70. if (!selectedFile) {
  71. toast.warning('请选择一个头像');
  72. return;
  73. }
  74. onChange(selectedFile.id);
  75. setIsOpen(false);
  76. setSelectedFile(null);
  77. };
  78. const handleCancel = () => {
  79. setIsOpen(false);
  80. setSelectedFile(null);
  81. };
  82. const handleUploadSuccess = (fileKey: string, fileUrl: string, file: any) => {
  83. toast.success('头像上传成功!请从列表中选择新上传的头像');
  84. };
  85. const getPreviewSize = () => {
  86. switch (previewSize) {
  87. case 'small':
  88. return 'h-16 w-16';
  89. case 'medium':
  90. return 'h-24 w-24';
  91. case 'large':
  92. return 'h-32 w-32';
  93. default:
  94. return 'h-24 w-24';
  95. }
  96. };
  97. const handleRemoveAvatar = (e: React.MouseEvent) => {
  98. e.stopPropagation();
  99. onChange(0);
  100. };
  101. return (
  102. <>
  103. <div className="space-y-4">
  104. {showPreview && (
  105. <div className="flex items-center space-x-4">
  106. <div className="relative group">
  107. <Avatar
  108. className={cn(
  109. getPreviewSize(),
  110. "border-2 border-dashed cursor-pointer hover:border-primary transition-colors"
  111. )}
  112. onClick={() => setIsOpen(true)}
  113. >
  114. {currentFile ? (
  115. <AvatarImage src={currentFile.fullUrl} alt={currentFile.name} />
  116. ) : (
  117. <AvatarFallback className="text-sm">
  118. {placeholder.charAt(0).toUpperCase()}
  119. </AvatarFallback>
  120. )}
  121. </Avatar>
  122. {currentFile && (
  123. <button
  124. type="button"
  125. className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
  126. onClick={handleRemoveAvatar}
  127. >
  128. <X className="h-3 w-3" />
  129. </button>
  130. )}
  131. </div>
  132. <div className="space-y-2">
  133. <Button
  134. type="button"
  135. variant="outline"
  136. onClick={() => setIsOpen(true)}
  137. className="text-sm"
  138. >
  139. {currentFile ? '更换头像' : placeholder}
  140. </Button>
  141. {currentFile && (
  142. <p className="text-xs text-muted-foreground">
  143. 当前: {currentFile.name}
  144. </p>
  145. )}
  146. </div>
  147. </div>
  148. )}
  149. {!showPreview && (
  150. <Button
  151. type="button"
  152. variant="outline"
  153. onClick={() => setIsOpen(true)}
  154. className="w-full"
  155. >
  156. {currentFile ? '更换头像' : placeholder}
  157. </Button>
  158. )}
  159. </div>
  160. <Dialog open={isOpen} onOpenChange={setIsOpen}>
  161. <DialogContent className="max-w-3xl max-h-[90vh]">
  162. <DialogHeader>
  163. <DialogTitle>选择头像</DialogTitle>
  164. <DialogDescription>
  165. 从已有头像中选择,或上传新头像
  166. </DialogDescription>
  167. </DialogHeader>
  168. <div className="space-y-4">
  169. {/* 上传区域 */}
  170. <Card>
  171. <CardContent className="pt-6">
  172. <MinioUploader
  173. uploadPath={uploadPath}
  174. accept={accept}
  175. maxSize={maxSize}
  176. onUploadSuccess={handleUploadSuccess}
  177. buttonText={uploadButtonText}
  178. />
  179. <p className="text-sm text-gray-500 mt-2">
  180. 上传后请从下方列表中选择新上传的头像
  181. </p>
  182. </CardContent>
  183. </Card>
  184. {/* 头像列表 */}
  185. <div className="space-y-2 max-h-96 overflow-y-auto">
  186. {isLoading ? (
  187. <Card>
  188. <CardContent className="text-center py-8">
  189. <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
  190. <p className="text-gray-500 mt-2">加载中...</p>
  191. </CardContent>
  192. </Card>
  193. ) : avatars.length > 0 ? (
  194. <div className="grid grid-cols-4 gap-3">
  195. {avatars.map((file) => (
  196. <div
  197. key={file.id}
  198. className={cn(
  199. "relative cursor-pointer transition-all duration-200",
  200. "hover:scale-105"
  201. )}
  202. onClick={() => handleSelectAvatar(file)}
  203. >
  204. <div
  205. className={cn(
  206. "relative rounded-lg overflow-hidden border-2",
  207. selectedFile?.id === file.id
  208. ? "border-primary ring-2 ring-primary ring-offset-2"
  209. : "border-gray-200 hover:border-primary"
  210. )}
  211. >
  212. <img
  213. src={file.fullUrl}
  214. alt={file.name}
  215. className="w-full h-24 object-cover"
  216. />
  217. {selectedFile?.id === file.id && (
  218. <div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
  219. <Check className="h-6 w-6 text-white bg-primary rounded-full p-1" />
  220. </div>
  221. )}
  222. <div className="absolute top-1 right-1">
  223. <Eye
  224. className="h-4 w-4 text-white bg-black/50 rounded-full p-0.5 cursor-pointer hover:bg-black/70"
  225. onClick={(e) => {
  226. e.stopPropagation();
  227. window.open(file.fullUrl, '_blank');
  228. }}
  229. />
  230. </div>
  231. </div>
  232. <p className="text-xs text-center mt-1 truncate">
  233. {file.name}
  234. </p>
  235. </div>
  236. ))}
  237. </div>
  238. ) : (
  239. <Card>
  240. <CardContent className="text-center py-8">
  241. <div className="flex flex-col items-center">
  242. <Upload className="h-12 w-12 text-gray-400 mb-4" />
  243. <p className="text-gray-600">暂无符合条件的头像</p>
  244. <p className="text-sm text-gray-500 mt-2">请先上传头像文件</p>
  245. </div>
  246. </CardContent>
  247. </Card>
  248. )}
  249. </div>
  250. </div>
  251. <DialogFooter>
  252. <Button type="button" variant="outline" onClick={handleCancel}>
  253. 取消
  254. </Button>
  255. <Button
  256. type="button"
  257. onClick={handleConfirm}
  258. disabled={!selectedFile}
  259. >
  260. 确认选择
  261. </Button>
  262. </DialogFooter>
  263. </DialogContent>
  264. </Dialog>
  265. </>
  266. );
  267. };
  268. export default AvatarSelector;