Explorar el Código

✨ feat(component): 添加文件选择器组件

- 实现支持单文件和多文件选择的FileSelector组件
- 集成MinioUploader支持文件上传功能
- 提供文件预览、选择状态管理和文件过滤功能
- 支持图片预览、文件类型图标显示和删除操作
- 适配不同预览尺寸和多种交互场景
yourname hace 3 meses
padre
commit
bda29cfbd0
Se han modificado 1 ficheros con 516 adiciones y 0 borrados
  1. 516 0
      src/client/admin-shadcn/components/FileSelector.tsx

+ 516 - 0
src/client/admin-shadcn/components/FileSelector.tsx

@@ -0,0 +1,516 @@
+import React, { useState, useEffect } 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 { toast } from 'sonner';
+import { fileClient } from '@/client/api';
+import MinioUploader from '@/client/admin-shadcn/components/MinioUploader';
+import { Check, Upload, Eye, X, File as FileIcon, Image as ImageIcon } from 'lucide-react';
+import { cn } from '@/client/lib/utils';
+import type { InferResponseType } from 'hono/client';
+
+type FileType = InferResponseType<typeof fileClient.$get, 200>['data'][0]
+
+export interface FileSelectorProps {
+  value?: number | null | number[];
+  onChange?: (fileId: number | null | number[]) => void;
+  accept?: string;
+  maxSize?: number;
+  uploadPath?: string;
+  uploadButtonText?: string;
+  previewSize?: 'small' | 'medium' | 'large';
+  showPreview?: boolean;
+  placeholder?: string;
+  title?: string;
+  description?: string;
+  filterType?: 'image' | 'all' | string;
+  allowMultiple?: boolean;
+}
+
+export const FileSelector: React.FC<FileSelectorProps> = ({
+  value,
+  onChange,
+  accept = '*/*',
+  maxSize = 10,
+  uploadPath = '/files',
+  uploadButtonText = '上传文件',
+  previewSize = 'medium',
+  showPreview = true,
+  placeholder = '选择文件',
+  title = '选择文件',
+  description = '上传新文件或从已有文件中选择',
+  filterType = 'all',
+  allowMultiple = false,
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
+  const [localSelectedFiles, setLocalSelectedFiles] = useState<number[]>([]);
+
+  // 获取当前选中的文件详情 - 支持单值和数组
+  const { data: currentFiles } = useQuery<FileType[]>({
+    queryKey: ['file-details', value, allowMultiple],
+    queryFn: async (): Promise<FileType[]> => {
+      if (!value) return [];
+      
+      // 处理多选模式下的数组值
+      if (allowMultiple && Array.isArray(value)) {
+        if (value.length === 0) return [];
+        
+        // 批量获取多个文件详情
+        const filePromises = value.map(async (fileId) => {
+          try {
+            const response = await fileClient[':id']['$get']({ param: { id: fileId.toString() } });
+            if (response.status === 200) {
+              return response.json();
+            }
+            return null;
+          } catch (error) {
+            console.error(`获取文件 ${fileId} 详情失败:`, error);
+            return null;
+          }
+        });
+        
+        const files = await Promise.all(filePromises);
+        return files.filter(file => file !== null);
+      }
+      
+      // 处理单选模式下的单值
+      if (!Array.isArray(value)) {
+        const response = await fileClient[':id']['$get']({ param: { id: value.toString() } });
+        if (response.status !== 200) throw new Error('获取文件详情失败');
+        return [await response.json()];
+      }
+      
+      return [];
+    },
+    enabled: !!value,
+  });
+
+  // 当对话框打开时,设置当前选中的文件
+  useEffect(() => {
+    if (isOpen) {
+      if (allowMultiple) {
+        // 在多选模式下,使用 value 数组初始化本地选择
+        const initialSelection = Array.isArray(value) ? value : [];
+        setLocalSelectedFiles(initialSelection);
+      } else if (value && currentFiles && currentFiles.length > 0) {
+        setSelectedFile(currentFiles[0]);
+      }
+    }
+  }, [isOpen, value, currentFiles, allowMultiple]);
+
+  // 获取文件列表
+  const { data: filesData, isLoading, refetch } = useQuery({
+    queryKey: ['files-for-selection', filterType] as const,
+    queryFn: async () => {
+      const response = await fileClient.$get({
+        query: {
+          page: 1,
+          pageSize: 50,
+          ...(filterType !== 'all' && { keyword: filterType })
+        }
+      });
+      if (response.status !== 200) throw new Error('获取文件列表失败');
+      return response.json();
+    },
+    enabled: isOpen,
+  });
+
+  const files = filesData?.data?.filter((f) => {
+    if (filterType === 'all') return true;
+    if (filterType === 'image') return f?.type?.startsWith('image/');
+    return f?.type?.includes(filterType);
+  }) || [];
+
+  const handleSelectFile = (file: FileType) => {
+    if (allowMultiple) {
+      setLocalSelectedFiles(prev => {
+        const newSelection = prev.includes(file.id)
+          ? prev.filter(id => id !== file.id)
+          : [...prev, file.id];
+        return newSelection;
+      });
+    } else {
+      setSelectedFile(prevSelected => {
+        if (prevSelected?.id === file.id) {
+          return null;
+        }
+        return file;
+      });
+    }
+  };
+
+  const handleConfirm = () => {
+    if (allowMultiple) {
+      if (onChange) {
+        onChange(localSelectedFiles);
+      }
+      setIsOpen(false);
+      return;
+    }
+
+    if (!selectedFile) {
+      toast.warning('请选择一个文件');
+      return;
+    }
+    if (onChange) {
+      onChange(selectedFile.id);
+    }
+    setIsOpen(false);
+    setSelectedFile(null);
+  };
+
+  const handleCancel = () => {
+    setIsOpen(false);
+    setSelectedFile(null);
+    // 取消时重置为初始的 value 值
+    const initialSelection = allowMultiple && Array.isArray(value) ? value : [];
+    setLocalSelectedFiles(initialSelection);
+  };
+
+  const handleUploadSuccess = () => {
+    toast.success('文件上传成功!请从列表中选择新上传的文件');
+    refetch();
+  };
+
+  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 getFileIcon = (fileType: string) => {
+    if (fileType.startsWith('image/')) {
+      return <ImageIcon className="h-8 w-8 text-gray-400" />;
+    }
+    if (fileType.startsWith('video/')) {
+      return <FileIcon className="h-8 w-8 text-blue-500" />;
+    }
+    if (fileType.startsWith('audio/')) {
+      return <FileIcon className="h-8 w-8 text-green-500" />;
+    }
+    if (fileType.includes('pdf')) {
+      return <FileIcon className="h-8 w-8 text-red-500" />;
+    }
+    if (fileType.includes('text')) {
+      return <FileIcon className="h-8 w-8 text-gray-600" />;
+    }
+    return <FileIcon className="h-8 w-8 text-gray-400" />;
+  };
+
+  const handleRemoveFile = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    if (allowMultiple && Array.isArray(value)) {
+      // 在多选模式下,移除所有选中文件
+      onChange?.([]);
+    } else {
+      // 在单选模式下,设置为null
+      onChange?.(null);
+    }
+  };
+
+  const isSelected = (fileId: number) => {
+    if (allowMultiple) {
+      return localSelectedFiles.includes(fileId);
+    }
+    return selectedFile?.id === fileId;
+  };
+
+  return (
+    <>
+      <div className="space-y-4">
+        {showPreview && (
+          <div className="flex items-start space-x-4">
+            {/* 预览区域 */}
+            <div className="flex flex-wrap gap-2">
+              {allowMultiple && Array.isArray(currentFiles) && currentFiles.length > 0 ? (
+                // 多选模式下的预览
+                currentFiles.map((file) => (
+                  <div key={file.id} className="relative group">
+                    <div
+                      className={cn(
+                        getPreviewSize(),
+                        "border-2 border-dashed cursor-pointer hover:border-primary transition-colors rounded-lg overflow-hidden flex items-center justify-center bg-gray-100"
+                      )}
+                      onClick={() => setIsOpen(true)}
+                    >
+                      {file?.type ? (
+                        <div className="w-full h-full flex items-center justify-center">
+                          {file.type.startsWith('image/') ? (
+                            <img
+                              src={file.fullUrl}
+                              alt={file.name}
+                              className="w-full h-full object-cover"
+                            />
+                          ) : (
+                            <div className="flex flex-col items-center justify-center text-gray-400">
+                              {getFileIcon(file.type)}
+                              <span className="text-xs mt-1 text-center px-1 truncate max-w-full">
+                                {file.name}
+                              </span>
+                            </div>
+                          )}
+                        </div>
+                      ) : (
+                        <div className="flex flex-col items-center justify-center text-gray-400">
+                          <FileIcon className="h-8 w-8 mb-1" />
+                          <span className="text-xs">{placeholder}</span>
+                        </div>
+                      )}
+                    </div>
+                    
+                    <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={(e) => {
+                        e.stopPropagation();
+                        if (allowMultiple && Array.isArray(value)) {
+                          const newValue = value.filter(id => id !== file.id);
+                          onChange?.(newValue);
+                        }
+                      }}
+                    >
+                      <X className="h-3 w-3" />
+                    </button>
+                  </div>
+                ))
+              ) : !allowMultiple && currentFiles && currentFiles.length > 0 ? (
+                // 单选模式下的预览
+                <div className="relative group">
+                  <div
+                    className={cn(
+                      getPreviewSize(),
+                      "border-2 border-dashed cursor-pointer hover:border-primary transition-colors rounded-lg overflow-hidden flex items-center justify-center bg-gray-100"
+                    )}
+                    onClick={() => setIsOpen(true)}
+                  >
+                    {currentFiles[0]?.type ? (
+                      <div className="w-full h-full flex items-center justify-center">
+                        {currentFiles[0].type.startsWith('image/') ? (
+                          <img
+                            src={currentFiles[0].fullUrl}
+                            alt={currentFiles[0].name}
+                            className="w-full h-full object-cover"
+                          />
+                        ) : (
+                          <div className="flex flex-col items-center justify-center text-gray-400">
+                            {getFileIcon(currentFiles[0].type)}
+                            <span className="text-xs mt-1 text-center">{currentFiles[0].name}</span>
+                          </div>
+                        )}
+                      </div>
+                    ) : (
+                      <div className="flex flex-col items-center justify-center text-gray-400">
+                        <FileIcon className="h-8 w-8 mb-1" />
+                        <span className="text-xs">{placeholder}</span>
+                      </div>
+                    )}
+                  </div>
+                  
+                  {currentFiles[0] && (
+                    <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={handleRemoveFile}
+                    >
+                      <X className="h-3 w-3" />
+                    </button>
+                  )}
+                </div>
+              ) : (
+                // 没有选中文件时的占位符
+                <div
+                  className={cn(
+                    getPreviewSize(),
+                    "border-2 border-dashed cursor-pointer hover:border-primary transition-colors rounded-lg overflow-hidden flex items-center justify-center bg-gray-100"
+                  )}
+                  onClick={() => setIsOpen(true)}
+                >
+                  <div className="flex flex-col items-center justify-center text-gray-400">
+                    <FileIcon className="h-8 w-8 mb-1" />
+                    <span className="text-xs">{placeholder}</span>
+                  </div>
+                </div>
+              )}
+            </div>
+            
+            <div className="space-y-2">
+              <Button
+                type="button"
+                variant="outline"
+                onClick={() => setIsOpen(true)}
+                className="text-sm"
+              >
+                {((allowMultiple && currentFiles && currentFiles.length > 0) ||
+                  (!allowMultiple && currentFiles && currentFiles.length > 0)) ? '更换文件' : placeholder}
+              </Button>
+              {!allowMultiple && currentFiles && currentFiles.length > 0 && (
+                <p className="text-xs text-muted-foreground truncate w-40 sm:w-64">
+                  当前: {currentFiles[0].name}
+                </p>
+              )}
+              {allowMultiple && currentFiles && currentFiles.length > 0 && (
+                <p className="text-xs text-muted-foreground">
+                  已选择 {currentFiles.length} 个文件
+                </p>
+              )}
+            </div>
+          </div>
+        )}
+
+        {!showPreview && (
+          <Button 
+            type="button" 
+            variant="outline" 
+            onClick={() => setIsOpen(true)}
+            className="w-full"
+          >
+            {currentFiles ? '更换文件' : placeholder}
+          </Button>
+        )}
+      </div>
+
+      <Dialog open={isOpen} onOpenChange={setIsOpen}>
+        <DialogContent className="max-w-4xl max-h-[90vh]">
+          <DialogHeader>
+            <DialogTitle>{title}</DialogTitle>
+            <DialogDescription>
+              {description}
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-4">
+            {/* 文件列表 */}
+            <div className="space-y-2 max-h-96 overflow-y-auto p-1">
+              {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>
+              ) : (
+                <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
+                  {/* 上传区域 - 作为第一项 */}
+                  <div className="relative cursor-pointer transition-all duration-200">
+                    <div className="rounded-lg border-2 border-dashed border-gray-300 hover:border-primary transition-colors hover:scale-105">
+                      <div className="p-2 h-20 flex items-center justify-center">
+                        <MinioUploader
+                          uploadPath={uploadPath}
+                          accept={accept}
+                          maxSize={maxSize}
+                          onUploadSuccess={handleUploadSuccess}
+                          buttonText="上传"
+                          size="minimal"
+                          displayMode="card"
+                          showUploadList={false}
+                        />
+                      </div>
+                    </div>
+                    <p className="text-xs text-center mt-1 text-muted-foreground">
+                      上传新文件
+                    </p>
+                  </div>
+
+                  {/* 现有文件列表 */}
+                  {files.map((file) => (
+                    <div
+                      key={file.id}
+                      className={cn(
+                        "relative cursor-pointer transition-all duration-200",
+                        "hover:scale-105"
+                      )}
+                      onClick={() => handleSelectFile(file)}
+                    >
+                      <div
+                        className={cn(
+                          "relative rounded-lg overflow-hidden border-2 aspect-square",
+                          isSelected(file.id)
+                            ? "border-primary ring-2 ring-primary ring-offset-2"
+                            : "border-gray-200 hover:border-primary"
+                        )}
+                      >
+                        {file?.type?.startsWith('image/') ? (
+                          <img
+                            src={file.fullUrl}
+                            alt={file.name}
+                            className="w-full h-full object-cover"
+                          />
+                        ) : (
+                          <div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 p-2">
+                            {file.type && getFileIcon(file.type)}
+                            <p className="text-xs text-center mt-1 truncate max-w-full">
+                              {file.name}
+                            </p>
+                          </div>
+                        )}
+                        
+                        {isSelected(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>
+                  ))}
+                  
+                  {/* 空状态 - 当没有文件时显示 */}
+                  {files.length === 0 && (
+                    <div className="col-span-full">
+                      <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>
+              )}
+            </div>
+          </div>
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={handleCancel}>
+              取消
+            </Button>
+            <Button
+              type="button"
+              onClick={handleConfirm}
+              disabled={allowMultiple ? localSelectedFiles.length === 0 : !selectedFile}
+            >
+              {allowMultiple ? `确认选择 (${localSelectedFiles.length})` : '确认选择'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+};
+
+export default FileSelector;