瀏覽代碼

✨ feat(components): add file preview and selector components

- 添加FilePreview组件,支持图片和文件预览,包含多种尺寸和计数显示
- 添加FileSelector组件,支持单/多选文件,集成上传功能和文件列表展示
- 实现文件选择状态管理和文件列表数据获取逻辑
- 支持文件类型过滤和上传后自动刷新文件列表
yourname 4 月之前
父節點
當前提交
5e3990ea5f
共有 2 個文件被更改,包括 466 次插入0 次删除
  1. 229 0
      src/client/admin-shadcn/components/FilePreview.tsx
  2. 237 0
      src/client/admin-shadcn/components/FileSelector.tsx

+ 229 - 0
src/client/admin-shadcn/components/FilePreview.tsx

@@ -0,0 +1,229 @@
+import React from 'react';
+import { Image, Spin, Empty } from 'antd';
+import { EyeOutlined, FileImageOutlined } from '@ant-design/icons';
+import { useQuery } from '@tanstack/react-query';
+import { fileClient } from '@/client/api';
+import type { InferResponseType } from 'hono/client';
+
+// 定义文件类型
+type FileItem = InferResponseType<typeof fileClient[':id']['$get'], 200>;
+
+interface FilePreviewProps {
+  fileIds?: number[];
+  files?: any[];
+  maxCount?: number;
+  size?: 'small' | 'medium' | 'large';
+  showCount?: boolean;
+  onFileClick?: (file: FileItem) => void;
+}
+
+interface FilePreviewItemProps {
+  file: FileItem;
+  size: 'small' | 'medium' | 'large';
+  index?: number;
+  total?: number;
+}
+
+const FilePreviewItem: React.FC<FilePreviewItemProps> = ({ file, size, index, total }) => {
+  const getSize = () => {
+    switch (size) {
+      case 'small':
+        return { width: 45, height: 45 };
+      case 'medium':
+        return { width: 80, height: 80 };
+      case 'large':
+        return { width: 120, height: 120 };
+      default:
+        return { width: 80, height: 80 };
+    }
+  };
+
+  const { width, height } = getSize();
+
+  const isImage = file.type?.startsWith('image/');
+  const previewText = isImage ? '预览' : '查看';
+
+  return (
+    <div
+      style={{
+        position: 'relative',
+        width,
+        height,
+        border: '1px solid #d9d9d9',
+        borderRadius: 4,
+        overflow: 'hidden',
+        backgroundColor: '#fafafa',
+      }}
+    >
+      {isImage ? (
+        <Image
+          src={file.fullUrl}
+          alt={file.name}
+          style={{
+            width: '100%',
+            height: '100%',
+            objectFit: 'cover',
+          }}
+          preview={{
+            mask: (
+              <div
+                style={{
+                  display: 'flex',
+                  flexDirection: 'column',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  color: 'white',
+                  backgroundColor: 'rgba(0,0,0,0.7)',
+                  height: '100%',
+                  fontSize: size === 'small' ? 10 : 12,
+                }}
+              >
+                <EyeOutlined style={{ fontSize: size === 'small' ? 14 : 16 }} />
+                <span style={{ marginTop: 2 }}>{previewText}</span>
+              </div>
+            ),
+          }}
+        />
+      ) : (
+        <div
+          style={{
+            display: 'flex',
+            flexDirection: 'column',
+            alignItems: 'center',
+            justifyContent: 'center',
+            height: '100%',
+            color: '#666',
+            fontSize: size === 'small' ? 10 : 12,
+          }}
+        >
+          <FileImageOutlined style={{ fontSize: size === 'small' ? 16 : 20, marginBottom: 2 }} />
+          <span style={{ textAlign: 'center', lineHeight: 1.2 }}>
+            {file.name.length > 8 ? `${file.name.substring(0, 6)}...` : file.name}
+          </span>
+        </div>
+      )}
+
+      {/* 序号标记 */}
+      {index !== undefined && total !== undefined && total > 1 && (
+        <div
+          style={{
+            position: 'absolute',
+            top: 0,
+            right: 0,
+            backgroundColor: 'rgba(0,0,0,0.5)',
+            color: 'white',
+            fontSize: 10,
+            padding: '2px 4px',
+            borderRadius: '0 0 0 4px',
+          }}
+        >
+          {index + 1}
+        </div>
+      )}
+    </div>
+  );
+};
+
+const FilePreview: React.FC<FilePreviewProps> = ({
+  fileIds = [],
+  files = [],
+  maxCount = 6,
+  size = 'medium',
+  showCount = true,
+  onFileClick,
+}) => {
+  // 合并文件ID和文件对象
+  const allFileIds = [...fileIds, ...(files?.map(f => f.id) || [])];
+  const uniqueFileIds = [...new Set(allFileIds)].filter(Boolean);
+
+  // 使用 React Query 查询文件详情
+  const { data: fileDetails, isLoading, error } = useQuery({
+    queryKey: ['files', uniqueFileIds],
+    queryFn: async () => {
+      if (uniqueFileIds.length === 0) return [];
+      
+      const promises = uniqueFileIds.map(async (id) => {
+        try {
+          const response = await fileClient[':id']['$get']({ param: { id: id.toString() } });
+          if (response.ok) {
+            return response.json();
+          }
+          return null;
+        } catch (error) {
+          console.error(`获取文件 ${id} 详情失败:`, error);
+          return null;
+        }
+      });
+      
+      const results = await Promise.all(promises);
+      return results.filter(Boolean) as FileItem[];
+    },
+    enabled: uniqueFileIds.length > 0,
+    staleTime: 5 * 60 * 1000, // 5分钟
+    gcTime: 10 * 60 * 1000, // 10分钟
+  });
+
+  if (isLoading) {
+    return (
+      <div style={{ display: 'flex', justifyContent: 'center', padding: 20 }}>
+        <Spin tip="加载图片中..." />
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <div style={{ display: 'flex', justifyContent: 'center', padding: 20 }}>
+        <Empty description="加载图片失败" />
+      </div>
+    );
+  }
+
+  const displayFiles = fileDetails?.slice(0, maxCount) || [];
+  const remainingCount = Math.max(0, (fileDetails?.length || 0) - maxCount);
+
+  if (displayFiles.length === 0) {
+    return (
+      <div style={{ display: 'flex', justifyContent: 'center', padding: 10 }}>
+        <Empty description="暂无图片" image={Empty.PRESENTED_IMAGE_SIMPLE} />
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      <div
+        style={{
+          display: 'flex',
+          flexWrap: 'wrap',
+          gap: 8,
+          alignItems: 'flex-start',
+        }}
+      >
+        {displayFiles.map((file, index) => (
+          <FilePreviewItem
+            key={file.id}
+            file={file}
+            size={size}
+            index={index}
+            total={displayFiles.length}
+          />
+        ))}
+      </div>
+      
+      {showCount && remainingCount > 0 && (
+        <div
+          style={{
+            marginTop: 8,
+            fontSize: 12,
+            color: '#666',
+          }}
+        >
+          还有 {remainingCount} 张图片未显示
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default FilePreview;

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

@@ -0,0 +1,237 @@
+import React, { useState } from 'react';
+import { Modal, Button, Image, message } from 'antd';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { fileClient } from '@/client/api';
+import type { FileType } from '@/server/modules/files/file.schema';
+import MinioUploader from '@/client/admin-shadcn/components/MinioUploader';
+
+// 定义重载的 props 类型
+interface SingleFileSelectorProps {
+  visible: boolean;
+  onCancel: () => void;
+  onSelect: (file: FileType) => void;
+  accept?: string;
+  maxSize?: number;
+  uploadPath?: string;
+  uploadButtonText?: string;
+  multiple?: false;
+}
+
+interface MultipleFileSelectorProps {
+  visible: boolean;
+  onCancel: () => void;
+  onSelect: (files: FileType[]) => void;
+  accept?: string;
+  maxSize?: number;
+  uploadPath?: string;
+  uploadButtonText?: string;
+  multiple: true;
+}
+
+type FileSelectorProps = SingleFileSelectorProps | MultipleFileSelectorProps;
+
+const FileSelector: React.FC<FileSelectorProps> = ({
+  visible,
+  onCancel,
+  onSelect,
+  accept = 'image/*',
+  maxSize = 5,
+  uploadPath = '/uploads',
+  uploadButtonText = '上传新文件',
+  multiple = false,
+  ...props
+}) => {
+  const queryClient = useQueryClient();
+  const [selectedFiles, setSelectedFiles] = useState<FileType[]>([]);
+  
+  // 获取文件列表
+  const { data: filesData, isLoading } = useQuery({
+    queryKey: ['files-for-selection', accept] as const,
+    queryFn: async () => {
+      const response = await fileClient.$get({
+        query: {
+          page: 1,
+          pageSize: 50,
+          keyword: accept.startsWith('image/') ? 'image' : undefined
+        }
+      });
+      if (response.status !== 200) throw new Error('获取文件列表失败');
+      return response.json();
+    },
+    enabled: visible
+  });
+
+  // 重置选择状态
+  React.useEffect(() => {
+    if (!visible) {
+      setSelectedFiles([]);
+    }
+  }, [visible]);
+
+  const handleSelectFile = (file: FileType) => {
+    if (!file) {
+      console.error('No file provided to handleSelectFile');
+      return;
+    }
+
+    if (multiple) {
+      // 多选模式
+      const newSelection = selectedFiles.some(f => f.id === file.id)
+        ? selectedFiles.filter(f => f.id !== file.id)
+        : [...selectedFiles, file];
+      
+      setSelectedFiles(newSelection);
+    } else {
+      // 单选模式
+      setSelectedFiles([file]);
+    }
+  };
+
+  const handleConfirm = () => {
+    if (selectedFiles.length === 0) {
+      message.warning('请先选择文件');
+      return;
+    }
+
+    if (multiple) {
+      // 多选模式
+      (onSelect as MultipleFileSelectorProps['onSelect'])(selectedFiles);
+    } else {
+      // 单选模式
+      (onSelect as SingleFileSelectorProps['onSelect'])(selectedFiles[0]);
+    }
+    
+    onCancel();
+  };
+
+  const handleUploadSuccess = (fileKey: string, fileUrl: string, file: any) => {
+    message.success('文件上传成功!请从列表中选择新上传的文件');
+    // 刷新文件列表
+    queryClient.invalidateQueries({ queryKey: ['files-for-selection', accept] });
+  };
+
+  const filteredFiles = Array.isArray(filesData?.data)
+    ? filesData.data.filter((f: any) => !accept || f?.type?.startsWith(accept.replace('*', '')))
+    : [];
+
+  const isFileSelected = (file: FileType) => {
+    return selectedFiles.some(f => f.id === file.id);
+  };
+
+  const getSelectionText = () => {
+    if (multiple) {
+      return selectedFiles.length > 0 ? `已选择 ${selectedFiles.length} 个文件` : '请选择文件';
+    }
+    return selectedFiles.length > 0 ? `已选择: ${selectedFiles[0].name}` : '请选择文件';
+  };
+
+  return (
+    <Modal
+      title="选择文件"
+      open={visible}
+      onCancel={onCancel}
+      width={800}
+      onOk={handleConfirm}
+      okText="确认选择"
+      cancelText="取消"
+      okButtonProps={{ disabled: selectedFiles.length === 0 }}
+    >
+      <div style={{ marginBottom: 16 }}>
+        <MinioUploader
+          uploadPath={uploadPath}
+          accept={accept}
+          maxSize={maxSize}
+          onUploadSuccess={handleUploadSuccess}
+          buttonText={uploadButtonText}
+        />
+        <span style={{ color: '#666', fontSize: '12px', marginLeft: 8 }}>
+          上传后请从下方列表中选择文件
+        </span>
+      </div>
+
+      <div style={{ marginBottom: 16 }}>
+        <div style={{
+          padding: '8px 12px',
+          backgroundColor: '#f6ffed',
+          border: '1px solid #b7eb8f',
+          borderRadius: '4px',
+          color: '#52c41a'
+        }}>
+          {getSelectionText()}
+        </div>
+      </div>
+
+      <div style={{ maxHeight: 400, overflowY: 'auto' }}>
+        {isLoading ? (
+          <div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
+            加载中...
+          </div>
+        ) : filteredFiles.length > 0 ? (
+          filteredFiles.map((file) => (
+            <div
+              key={file.id}
+              style={{
+                display: 'flex',
+                alignItems: 'center',
+                padding: '12px',
+                border: `1px solid ${isFileSelected(file) ? '#1890ff' : '#f0f0f0'}`,
+                borderRadius: '4px',
+                marginBottom: '8px',
+                cursor: 'pointer',
+                transition: 'all 0.3s',
+                backgroundColor: isFileSelected(file) ? '#e6f7ff' : ''
+              }}
+              onClick={() => handleSelectFile(file)}
+              onMouseEnter={(e) => {
+                if (!isFileSelected(file)) {
+                  e.currentTarget.style.backgroundColor = '#f6ffed';
+                  e.currentTarget.style.borderColor = '#b7eb8f';
+                }
+              }}
+              onMouseLeave={(e) => {
+                if (!isFileSelected(file)) {
+                  e.currentTarget.style.backgroundColor = '';
+                  e.currentTarget.style.borderColor = '#f0f0f0';
+                }
+              }}
+            >
+              <Image
+                src={file.fullUrl}
+                alt={file.name}
+                style={{ width: 60, height: 60, objectFit: 'cover', borderRadius: 4, marginRight: 12 }}
+                preview={false}
+              />
+              <div style={{ flex: 1 }}>
+                <div style={{ fontWeight: 'bold', marginBottom: '4px' }}>{file.name}</div>
+                <div style={{ color: '#666', fontSize: '12px' }}>
+                  ID: {file.id} | 大小: {((file.size || 0) / 1024 / 1024).toFixed(2)}MB
+                </div>
+              </div>
+              <Button
+                type={isFileSelected(file) ? 'primary' : 'default'}
+                size="small"
+              >
+                {isFileSelected(file) ? '已选择' : '选择'}
+              </Button>
+            </div>
+          ))
+        ) : (
+          <div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
+            暂无符合条件的文件,请先上传
+          </div>
+        )}
+      </div>
+    </Modal>
+  );
+};
+
+// 类型守卫函数
+function isMultipleSelector(props: FileSelectorProps): props is MultipleFileSelectorProps {
+  return props.multiple === true;
+}
+
+function isSingleSelector(props: FileSelectorProps): props is SingleFileSelectorProps {
+  return props.multiple === false || props.multiple === undefined;
+}
+
+export default FileSelector;