Browse Source

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

- 新增ImageSelector组件,支持图片上传和选择功能
- 实现单图/多图选择模式,支持预览和删除操作
- 添加图片上传、预览、选择功能,支持从已有图片中选择

♻️ refactor(schema): 优化广告类型定义

- 为广告相关schema中的number类型添加泛型约束,增强类型安全性
- 统一调整typeId、imageFileId等字段的类型定义
yourname 4 months ago
parent
commit
ff53b35bab

+ 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;

+ 10 - 10
src/server/modules/advertisements/advertisement.schema.ts

@@ -70,7 +70,7 @@ export const CreateAdvertisementDto = z.object({
     description: '标题',
     description: '标题',
     example: '首页轮播图'
     example: '首页轮播图'
   }),
   }),
-  typeId: z.coerce.number().int().positive().openapi({
+  typeId: z.coerce.number<number>().int().positive().openapi({
     description: '广告类型',
     description: '广告类型',
     example: 1
     example: 1
   }),
   }),
@@ -82,19 +82,19 @@ export const CreateAdvertisementDto = z.object({
     description: '跳转URL',
     description: '跳转URL',
     example: 'https://example.com'
     example: 'https://example.com'
   }),
   }),
-  imageFileId: z.coerce.number().int().positive().optional().openapi({
+  imageFileId: z.coerce.number<number>().int().positive().optional().openapi({
     description: '图片文件ID',
     description: '图片文件ID',
     example: 1
     example: 1
   }),
   }),
-  sort: z.coerce.number().int().default(0).optional().openapi({
+  sort: z.coerce.number<number>().int().default(0).optional().openapi({
     description: '排序值',
     description: '排序值',
     example: 10
     example: 10
   }),
   }),
-  status: z.coerce.number().int().min(0).max(1).default(0).optional().openapi({
+  status: z.coerce.number<number>().int().min(0).max(1).default(0).optional().openapi({
     description: '状态 0禁用 1启用',
     description: '状态 0禁用 1启用',
     example: 1
     example: 1
   }),
   }),
-  actionType: z.coerce.number().int().min(0).max(2).default(1).optional().openapi({
+  actionType: z.coerce.number<number>().int().min(0).max(2).default(1).optional().openapi({
     description: '跳转类型 0不跳转 1webview 2小程序页面',
     description: '跳转类型 0不跳转 1webview 2小程序页面',
     example: 1
     example: 1
   })
   })
@@ -106,7 +106,7 @@ export const UpdateAdvertisementDto = z.object({
     description: '标题',
     description: '标题',
     example: '首页轮播图'
     example: '首页轮播图'
   }),
   }),
-  typeId: z.coerce.number().int().positive().optional().openapi({
+  typeId: z.coerce.number<number>().int().positive().optional().openapi({
     description: '广告类型',
     description: '广告类型',
     example: 1
     example: 1
   }),
   }),
@@ -118,19 +118,19 @@ export const UpdateAdvertisementDto = z.object({
     description: '跳转URL',
     description: '跳转URL',
     example: 'https://example.com'
     example: 'https://example.com'
   }),
   }),
-  imageFileId: z.coerce.number().int().positive().optional().openapi({
+  imageFileId: z.coerce.number<number>().int().positive().optional().openapi({
     description: '图片文件ID',
     description: '图片文件ID',
     example: 1
     example: 1
   }),
   }),
-  sort: z.coerce.number().int().optional().openapi({
+  sort: z.coerce.number<number>().int().optional().openapi({
     description: '排序值',
     description: '排序值',
     example: 10
     example: 10
   }),
   }),
-  status: z.coerce.number().int().min(0).max(1).optional().openapi({
+  status: z.coerce.number<number>().int().min(0).max(1).optional().openapi({
     description: '状态 0禁用 1启用',
     description: '状态 0禁用 1启用',
     example: 1
     example: 1
   }),
   }),
-  actionType: z.coerce.number().int().min(0).max(2).optional().openapi({
+  actionType: z.coerce.number<number>().int().min(0).max(2).optional().openapi({
     description: '跳转类型 0不跳转 1webview 2小程序页面',
     description: '跳转类型 0不跳转 1webview 2小程序页面',
     example: 1
     example: 1
   })
   })