AvatarSelector.tsx 11 KB

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