AvatarSelector.tsx 10 KB

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