AvatarSelector.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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(prevSelected => {
  68. // 如果点击的是已选中的头像,则取消选择
  69. if (prevSelected?.id === file.id) {
  70. return null;
  71. }
  72. // 否则选择新的头像
  73. return file;
  74. });
  75. };
  76. const handleConfirm = () => {
  77. if (!selectedFile) {
  78. toast.warning('请选择一个头像');
  79. return;
  80. }
  81. onChange(selectedFile.id);
  82. setIsOpen(false);
  83. setSelectedFile(null);
  84. };
  85. const handleCancel = () => {
  86. setIsOpen(false);
  87. setSelectedFile(null);
  88. };
  89. const handleUploadSuccess = (fileKey: string, fileUrl: string, file: any) => {
  90. toast.success('头像上传成功!请从列表中选择新上传的头像');
  91. };
  92. const getPreviewSize = () => {
  93. switch (previewSize) {
  94. case 'small':
  95. return 'h-16 w-16';
  96. case 'medium':
  97. return 'h-24 w-24';
  98. case 'large':
  99. return 'h-32 w-32';
  100. default:
  101. return 'h-24 w-24';
  102. }
  103. };
  104. const handleRemoveAvatar = (e: React.MouseEvent) => {
  105. e.stopPropagation();
  106. onChange(0);
  107. };
  108. return (
  109. <>
  110. <div className="space-y-4">
  111. {showPreview && (
  112. <div className="flex items-center space-x-4">
  113. <div className="relative group">
  114. <Avatar
  115. className={cn(
  116. getPreviewSize(),
  117. "border-2 border-dashed cursor-pointer hover:border-primary transition-colors"
  118. )}
  119. onClick={() => setIsOpen(true)}
  120. >
  121. {currentFile ? (
  122. <AvatarImage src={currentFile.fullUrl} alt={currentFile.name} />
  123. ) : (
  124. <AvatarFallback className="text-sm">
  125. {placeholder.charAt(0).toUpperCase()}
  126. </AvatarFallback>
  127. )}
  128. </Avatar>
  129. {currentFile && (
  130. <button
  131. type="button"
  132. className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
  133. onClick={handleRemoveAvatar}
  134. >
  135. <X className="h-3 w-3" />
  136. </button>
  137. )}
  138. </div>
  139. <div className="space-y-2">
  140. <Button
  141. type="button"
  142. variant="outline"
  143. onClick={() => setIsOpen(true)}
  144. className="text-sm"
  145. >
  146. {currentFile ? '更换头像' : placeholder}
  147. </Button>
  148. {currentFile && (
  149. <p className="text-xs text-muted-foreground">
  150. 当前: {currentFile.name}
  151. </p>
  152. )}
  153. </div>
  154. </div>
  155. )}
  156. {!showPreview && (
  157. <Button
  158. type="button"
  159. variant="outline"
  160. onClick={() => setIsOpen(true)}
  161. className="w-full"
  162. >
  163. {currentFile ? '更换头像' : placeholder}
  164. </Button>
  165. )}
  166. </div>
  167. <Dialog open={isOpen} onOpenChange={setIsOpen}>
  168. <DialogContent className="max-w-3xl max-h-[90vh]">
  169. <DialogHeader>
  170. <DialogTitle>选择头像</DialogTitle>
  171. <DialogDescription>
  172. 从已有头像中选择,或上传新头像
  173. </DialogDescription>
  174. </DialogHeader>
  175. <div className="space-y-4">
  176. {/* 上传区域 */}
  177. <Card>
  178. <CardContent className="pt-6">
  179. <MinioUploader
  180. uploadPath={uploadPath}
  181. accept={accept}
  182. maxSize={maxSize}
  183. onUploadSuccess={handleUploadSuccess}
  184. buttonText={uploadButtonText}
  185. size="minimal"
  186. />
  187. <p className="text-sm text-gray-500 mt-2">
  188. 上传后请从下方列表中选择新上传的头像
  189. </p>
  190. </CardContent>
  191. </Card>
  192. {/* 头像列表 */}
  193. <div className="space-y-2 max-h-96 overflow-y-auto">
  194. {isLoading ? (
  195. <Card>
  196. <CardContent className="text-center py-8">
  197. <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
  198. <p className="text-gray-500 mt-2">加载中...</p>
  199. </CardContent>
  200. </Card>
  201. ) : avatars.length > 0 ? (
  202. <div className="grid grid-cols-4 gap-3">
  203. {avatars.map((file) => (
  204. <div
  205. key={file.id}
  206. className={cn(
  207. "relative cursor-pointer transition-all duration-200",
  208. "hover:scale-105"
  209. )}
  210. onClick={() => handleSelectAvatar(file)}
  211. >
  212. <div
  213. className={cn(
  214. "relative rounded-lg overflow-hidden border-2",
  215. selectedFile?.id === file.id
  216. ? "border-primary ring-2 ring-primary ring-offset-2"
  217. : "border-gray-200 hover:border-primary"
  218. )}
  219. >
  220. <img
  221. src={file.fullUrl}
  222. alt={file.name}
  223. className="w-full h-24 object-cover"
  224. />
  225. {selectedFile?.id === file.id && (
  226. <div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
  227. <Check className="h-6 w-6 text-white bg-primary rounded-full p-1" />
  228. </div>
  229. )}
  230. <div className="absolute top-1 right-1">
  231. <Eye
  232. className="h-4 w-4 text-white bg-black/50 rounded-full p-0.5 cursor-pointer hover:bg-black/70"
  233. onClick={(e) => {
  234. e.stopPropagation();
  235. window.open(file.fullUrl, '_blank');
  236. }}
  237. />
  238. </div>
  239. </div>
  240. <p className="text-xs text-center mt-1 truncate">
  241. {file.name}
  242. </p>
  243. </div>
  244. ))}
  245. </div>
  246. ) : (
  247. <Card>
  248. <CardContent className="text-center py-8">
  249. <div className="flex flex-col items-center">
  250. <Upload className="h-12 w-12 text-gray-400 mb-4" />
  251. <p className="text-gray-600">暂无符合条件的头像</p>
  252. <p className="text-sm text-gray-500 mt-2">请先上传头像文件</p>
  253. </div>
  254. </CardContent>
  255. </Card>
  256. )}
  257. </div>
  258. </div>
  259. <DialogFooter>
  260. <Button type="button" variant="outline" onClick={handleCancel}>
  261. 取消
  262. </Button>
  263. <Button
  264. type="button"
  265. onClick={handleConfirm}
  266. disabled={!selectedFile}
  267. >
  268. 确认选择
  269. </Button>
  270. </DialogFooter>
  271. </DialogContent>
  272. </Dialog>
  273. </>
  274. );
  275. };
  276. export default AvatarSelector;