AvatarSelector.tsx 11 KB

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