Browse Source

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

- 实现图片上传与选择功能,支持单张/多张图片选择
- 提供图片预览、删除和更换功能
- 支持从已有图片列表中选择或上传新图片
- 可配置上传路径、文件类型、大小限制等参数
- 适配不同预览尺寸和显示模式需求
yourname 3 months ago
parent
commit
0e74fe4ffd
1 changed files with 367 additions and 0 deletions
  1. 367 0
      src/client/admin-shadcn/components/ImageSelector.tsx

+ 367 - 0
src/client/admin-shadcn/components/ImageSelector.tsx

@@ -0,0 +1,367 @@
+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, 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]
+
+interface ImageSelectorProps {
+  value?: number | null;
+  onChange: (fileId: number | null) => 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;
+  selectedFiles?: number[];
+  onMultipleSelect?: (fileIds: number[]) => void;
+}
+
+const ImageSelector: React.FC<ImageSelectorProps> = ({
+  value,
+  onChange,
+  accept = 'image/*',
+  maxSize = 5,
+  uploadPath = '/images',
+  uploadButtonText = '上传图片',
+  previewSize = 'medium',
+  showPreview = true,
+  placeholder = '选择图片',
+  title = '选择图片',
+  description = '上传新图片或从已有图片中选择',
+  filterType = 'image',
+  allowMultiple = false,
+  selectedFiles = [],
+  onMultipleSelect,
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
+  const [localSelectedFiles, setLocalSelectedFiles] = useState<number[]>(selectedFiles);
+
+  // 获取当前选中的文件详情
+  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,
+  });
+
+  // 当对话框打开时,设置当前选中的图片
+  useEffect(() => {
+    if (isOpen && value && currentFile) {
+      setSelectedFile(currentFile);
+    }
+  }, [isOpen, value, currentFile]);
+
+  // 当allowMultiple模式下的selectedFiles变化时
+  useEffect(() => {
+    setLocalSelectedFiles(selectedFiles);
+  }, [selectedFiles]);
+
+  // 获取图片列表
+  const { data: filesData, isLoading, refetch } = useQuery({
+    queryKey: ['images-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 images = filesData?.data?.filter((f: any) => {
+    if (filterType === 'all') return true;
+    if (filterType === 'image') return f?.type?.startsWith('image/');
+    return f?.type?.includes(filterType);
+  }) || [];
+
+  const handleSelectImage = (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 (onMultipleSelect) {
+        onMultipleSelect(localSelectedFiles);
+      }
+      setIsOpen(false);
+      return;
+    }
+
+    if (!selectedFile) {
+      toast.warning('请选择一个图片');
+      return;
+    }
+    onChange(selectedFile.id);
+    setIsOpen(false);
+    setSelectedFile(null);
+  };
+
+  const handleCancel = () => {
+    setIsOpen(false);
+    setSelectedFile(null);
+    setLocalSelectedFiles(selectedFiles);
+  };
+
+  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 handleRemoveImage = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    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-center space-x-4">
+            <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)}
+              >
+                {currentFile ? (
+                  <img 
+                    src={currentFile.fullUrl} 
+                    alt={currentFile.name} 
+                    className="w-full h-full object-cover"
+                  />
+                ) : (
+                  <div className="flex flex-col items-center justify-center text-gray-400">
+                    <ImageIcon className="h-8 w-8 mb-1" />
+                    <span className="text-xs">{placeholder}</span>
+                  </div>
+                )}
+              </div>
+              
+              {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={handleRemoveImage}
+                >
+                  <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 truncate w-40 sm:w-64">
+                  当前: {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-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-24 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>
+
+                  {/* 现有图片列表 */}
+                  {images.map((file) => (
+                    <div
+                      key={file.id}
+                      className={cn(
+                        "relative cursor-pointer transition-all duration-200",
+                        "hover:scale-105"
+                      )}
+                      onClick={() => handleSelectImage(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"
+                        )}
+                      >
+                        <img
+                          src={file.fullUrl}
+                          alt={file.name}
+                          className="w-full h-full object-cover"
+                        />
+                        
+                        {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>
+                  ))}
+                  
+                  {/* 空状态 - 当没有图片时显示 */}
+                  {images.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 ImageSelector;