Forráskód Böngészése

✨ feat(admin): 实现头像选择器组件并集成到用户管理页面

- 创建 AvatarSelector 组件,支持头像上传、预览和选择功能
- 支持图片上传、已上传头像列表展示、头像预览和删除功能
- 集成 MinioUploader 组件处理文件上传
- 在用户创建和编辑表单中替换原文件选择器为新头像选择器
- 优化头像选择交互体验,添加预览、放大和选择状态反馈
- 移除独立的头像选择器对话框,将功能集成到组件内部
yourname 4 hónapja
szülő
commit
f219c02e96

+ 291 - 0
src/client/admin-shadcn/components/AvatarSelector.tsx

@@ -0,0 +1,291 @@
+import React, { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Button } from '@/client/components/ui/button';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
+import { Card, CardContent } from '@/client/components/ui/card';
+import { Badge } from '@/client/components/ui/badge';
+import { toast } from 'sonner';
+import { fileClient } from '@/client/api';
+import type { FileType } from '@/server/modules/files/file.schema';
+import MinioUploader from '@/client/admin-shadcn/components/MinioUploader';
+import { Check, Upload, Eye, X } from 'lucide-react';
+import { Avatar, AvatarFallback, AvatarImage } from '@/client/components/ui/avatar';
+import { cn } from '@/client/lib/utils';
+
+interface AvatarSelectorProps {
+  value?: number;
+  onChange: (fileId: number) => void;
+  accept?: string;
+  maxSize?: number;
+  uploadPath?: string;
+  uploadButtonText?: string;
+  previewSize?: 'small' | 'medium' | 'large';
+  showPreview?: boolean;
+  placeholder?: string;
+}
+
+const AvatarSelector: React.FC<AvatarSelectorProps> = ({
+  value,
+  onChange,
+  accept = 'image/*',
+  maxSize = 2,
+  uploadPath = '/avatars',
+  uploadButtonText = '上传头像',
+  previewSize = 'medium',
+  showPreview = true,
+  placeholder = '选择头像',
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
+
+  // 获取当前选中的文件详情
+  const { data: currentFile } = useQuery({
+    queryKey: ['file-detail', value],
+    queryFn: async () => {
+      if (!value) return null;
+      const response = await fileClient[':id']['$get']({ param: { id: value.toString() } });
+      if (response.status !== 200) throw new Error('获取文件详情失败');
+      return response.json();
+    },
+    enabled: !!value,
+  });
+
+  // 获取头像列表
+  const { data: filesData, isLoading } = useQuery({
+    queryKey: ['avatars-for-selection'] as const,
+    queryFn: async () => {
+      const response = await fileClient.$get({
+        query: {
+          page: 1,
+          pageSize: 50,
+          keyword: 'image'
+        }
+      });
+      if (response.status !== 200) throw new Error('获取头像列表失败');
+      return response.json();
+    },
+    enabled: isOpen,
+  });
+
+  const avatars = filesData?.data?.filter((f: any) => f?.type?.startsWith('image/')) || [];
+
+  const handleSelectAvatar = (file: FileType) => {
+    setSelectedFile(file);
+  };
+
+  const handleConfirm = () => {
+    if (!selectedFile) {
+      toast.warning('请选择一个头像');
+      return;
+    }
+    onChange(selectedFile.id);
+    setIsOpen(false);
+    setSelectedFile(null);
+  };
+
+  const handleCancel = () => {
+    setIsOpen(false);
+    setSelectedFile(null);
+  };
+
+  const handleUploadSuccess = (fileKey: string, fileUrl: string, file: any) => {
+    toast.success('头像上传成功!请从列表中选择新上传的头像');
+  };
+
+  const getPreviewSize = () => {
+    switch (previewSize) {
+      case 'small':
+        return 'h-16 w-16';
+      case 'medium':
+        return 'h-24 w-24';
+      case 'large':
+        return 'h-32 w-32';
+      default:
+        return 'h-24 w-24';
+    }
+  };
+
+  const handleRemoveAvatar = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    onChange(0);
+  };
+
+  return (
+    <>
+      <div className="space-y-4">
+        {showPreview && (
+          <div className="flex items-center space-x-4">
+            <div className="relative group">
+              <Avatar 
+                className={cn(
+                  getPreviewSize(),
+                  "border-2 border-dashed cursor-pointer hover:border-primary transition-colors"
+                )}
+                onClick={() => setIsOpen(true)}
+              >
+                {currentFile ? (
+                  <AvatarImage src={currentFile.fullUrl} alt={currentFile.name} />
+                ) : (
+                  <AvatarFallback className="text-sm">
+                    {placeholder.charAt(0).toUpperCase()}
+                  </AvatarFallback>
+                )}
+              </Avatar>
+              
+              {currentFile && (
+                <button
+                  type="button"
+                  className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
+                  onClick={handleRemoveAvatar}
+                >
+                  <X className="h-3 w-3" />
+                </button>
+              )}
+            </div>
+            
+            <div className="space-y-2">
+              <Button 
+                type="button" 
+                variant="outline" 
+                onClick={() => setIsOpen(true)}
+                className="text-sm"
+              >
+                {currentFile ? '更换头像' : placeholder}
+              </Button>
+              {currentFile && (
+                <p className="text-xs text-muted-foreground">
+                  当前: {currentFile.name}
+                </p>
+              )}
+            </div>
+          </div>
+        )}
+
+        {!showPreview && (
+          <Button 
+            type="button" 
+            variant="outline" 
+            onClick={() => setIsOpen(true)}
+            className="w-full"
+          >
+            {currentFile ? '更换头像' : placeholder}
+          </Button>
+        )}
+      </div>
+
+      <Dialog open={isOpen} onOpenChange={setIsOpen}>
+        <DialogContent className="max-w-3xl max-h-[90vh]">
+          <DialogHeader>
+            <DialogTitle>选择头像</DialogTitle>
+            <DialogDescription>
+              从已有头像中选择,或上传新头像
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-4">
+            {/* 上传区域 */}
+            <Card>
+              <CardContent className="pt-6">
+                <MinioUploader
+                  uploadPath={uploadPath}
+                  accept={accept}
+                  maxSize={maxSize}
+                  onUploadSuccess={handleUploadSuccess}
+                  buttonText={uploadButtonText}
+                />
+                <p className="text-sm text-gray-500 mt-2">
+                  上传后请从下方列表中选择新上传的头像
+                </p>
+              </CardContent>
+            </Card>
+
+            {/* 头像列表 */}
+            <div className="space-y-2 max-h-96 overflow-y-auto">
+              {isLoading ? (
+                <Card>
+                  <CardContent className="text-center py-8">
+                    <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
+                    <p className="text-gray-500 mt-2">加载中...</p>
+                  </CardContent>
+                </Card>
+              ) : avatars.length > 0 ? (
+                <div className="grid grid-cols-4 gap-3">
+                  {avatars.map((file) => (
+                    <div
+                      key={file.id}
+                      className={cn(
+                        "relative cursor-pointer transition-all duration-200",
+                        "hover:scale-105"
+                      )}
+                      onClick={() => handleSelectAvatar(file)}
+                    >
+                      <div
+                        className={cn(
+                          "relative rounded-lg overflow-hidden border-2",
+                          selectedFile?.id === file.id
+                            ? "border-primary ring-2 ring-primary ring-offset-2"
+                            : "border-gray-200 hover:border-primary"
+                        )}
+                      >
+                        <img
+                          src={file.fullUrl}
+                          alt={file.name}
+                          className="w-full h-24 object-cover"
+                        />
+                        
+                        {selectedFile?.id === file.id && (
+                          <div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
+                            <Check className="h-6 w-6 text-white bg-primary rounded-full p-1" />
+                          </div>
+                        )}
+                        
+                        <div className="absolute top-1 right-1">
+                          <Eye 
+                            className="h-4 w-4 text-white bg-black/50 rounded-full p-0.5 cursor-pointer hover:bg-black/70"
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              window.open(file.fullUrl, '_blank');
+                            }}
+                          />
+                        </div>
+                      </div>
+                      
+                      <p className="text-xs text-center mt-1 truncate">
+                        {file.name}
+                      </p>
+                    </div>
+                  ))}
+                </div>
+              ) : (
+                <Card>
+                  <CardContent className="text-center py-8">
+                    <div className="flex flex-col items-center">
+                      <Upload className="h-12 w-12 text-gray-400 mb-4" />
+                      <p className="text-gray-600">暂无符合条件的头像</p>
+                      <p className="text-sm text-gray-500 mt-2">请先上传头像文件</p>
+                    </div>
+                  </CardContent>
+                </Card>
+              )}
+            </div>
+          </div>
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={handleCancel}>
+              取消
+            </Button>
+            <Button 
+              type="button" 
+              onClick={handleConfirm}
+              disabled={!selectedFile}
+            >
+              确认选择
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+};
+
+export default AvatarSelector;

+ 21 - 34
src/client/admin-shadcn/pages/Users.tsx

@@ -12,7 +12,7 @@ import { Badge } from '@/client/components/ui/badge';
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
 import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
 import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination';
-import FileSelector from '@/client/admin-shadcn/components/FileSelector';
+import AvatarSelector from '@/client/admin-shadcn/components/AvatarSelector';
 import { useForm } from 'react-hook-form';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { toast } from 'sonner';
@@ -43,7 +43,7 @@ export const UsersPage = () => {
   const [editingUser, setEditingUser] = useState<any>(null);
   const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
   const [userToDelete, setUserToDelete] = useState<number | null>(null);
-  const [isAvatarSelectorOpen, setIsAvatarSelectorOpen] = useState(false);
+  // Avatar selector is now integrated, no separate state needed
 
   const [isCreateForm, setIsCreateForm] = useState(true);
   
@@ -448,13 +448,15 @@ export const UsersPage = () => {
                     <FormItem>
                       <FormLabel>头像</FormLabel>
                       <FormControl>
-                        <Button
-                          type="button"
-                          variant="outline"
-                          onClick={() => setIsAvatarSelectorOpen(true)}
-                        >
-                          选择头像
-                        </Button>
+                        <AvatarSelector
+                          value={field.value || undefined}
+                          onChange={(value) => field.onChange(value)}
+                          maxSize={2}
+                          uploadPath="/avatars"
+                          uploadButtonText="上传头像"
+                          previewSize="medium"
+                          placeholder="选择头像"
+                        />
                       </FormControl>
                       <FormMessage />
                     </FormItem>
@@ -589,13 +591,15 @@ export const UsersPage = () => {
                     <FormItem>
                       <FormLabel>头像</FormLabel>
                       <FormControl>
-                        <Button
-                          type="button"
-                          variant="outline"
-                          onClick={() => setIsAvatarSelectorOpen(true)}
-                        >
-                          选择头像
-                        </Button>
+                        <AvatarSelector
+                          value={field.value || undefined}
+                          onChange={(value) => field.onChange(value)}
+                          maxSize={2}
+                          uploadPath="/avatars"
+                          uploadButtonText="上传头像"
+                          previewSize="medium"
+                          placeholder="选择头像"
+                        />
                       </FormControl>
                       <FormMessage />
                     </FormItem>
@@ -637,24 +641,7 @@ export const UsersPage = () => {
         </DialogContent>
       </Dialog>
 
-      {/* 头像选择器 */}
-      <FileSelector
-        visible={isAvatarSelectorOpen}
-        onCancel={() => setIsAvatarSelectorOpen(false)}
-        onSelect={(file) => {
-          if (isCreateForm) {
-            createForm.setValue('avatarFileId', file.id);
-          } else {
-            updateForm.setValue('avatarFileId', file.id);
-          }
-          setIsAvatarSelectorOpen(false);
-        }}
-        accept="image/*"
-        maxSize={2}
-        uploadPath="/avatars"
-        uploadButtonText="上传头像"
-        multiple={false}
-      />
+      {/* Avatar selector is now integrated within the form */}
 
       {/* 删除确认对话框 */}
       <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>