Explorar el Código

✨ feat(admin): 添加通用文件预览组件FilePreview

- 创建新的FilePreview组件,支持多种尺寸(small/medium/large)和最大显示数量配置
- 实现图片文件预览功能,非图片文件显示图标和文件名
- 添加序号标记和剩余数量提示功能
- 支持通过fileIds或files属性传入文件数据

♻️ refactor(admin): 优化PolicyNewsPage中的文件预览功能

- 移除PolicyNewsPage中的内联FilePreview组件,统一使用新的FilePreview组件
- 优化文件ID处理逻辑,合并并去重文件ID
- 调整图片预览布局和样式,统一视觉风格
- 修复文件预览中的类型定义问题

💄 style(admin): 改进FileSelector组件代码风格

- 移除文件映射中的显式类型声明,使用类型推断
yourname hace 7 meses
padre
commit
0297b1bf00

+ 229 - 0
src/client/admin/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;

+ 1 - 1
src/client/admin/components/FileSelector.tsx

@@ -167,7 +167,7 @@ const FileSelector: React.FC<FileSelectorProps> = ({
             加载中...
           </div>
         ) : filteredFiles.length > 0 ? (
-          filteredFiles.map((file: FileType) => (
+          filteredFiles.map((file) => (
             <div
               key={file.id}
               style={{

+ 15 - 95
src/client/admin/pages/PolicyNewsPage.tsx

@@ -38,6 +38,7 @@ import { uploadFile } from '@/client/utils/minio';
 import ReactQuill from 'react-quill-new';
 import 'react-quill-new/dist/quill.snow.css';
 import FileSelector from '@/client/admin/components/FileSelector';
+import FilePreview from '@/client/admin/components/FilePreview';
 
 const { Search } = Input;
 const { Option } = Select;
@@ -360,39 +361,14 @@ const PolicyNewsPage: React.FC = () => {
           return <span style={{ color: '#999', fontSize: 12 }}>无图片</span>;
         }
         
-        const imageFiles = files.filter(file => file?.type?.startsWith('image/'));
-        if (imageFiles.length === 0) {
-          return <span style={{ color: '#999', fontSize: 12 }}>无图片</span>;
-        }
-        
-        const firstImage = imageFiles[0];
-        
         return (
-          <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
-            <Image
-              width={45}
-              height={45}
-              src={firstImage.fullUrl}
-              style={{ objectFit: 'cover', borderRadius: 4 }}
-              preview={{
-                mask: <span style={{ fontSize: 12 }}>预览</span>
-              }}
+          <div style={{ width: 90 }}>
+            <FilePreview
+              files={files}
+              maxCount={1}
+              size="small"
+              showCount={false}
             />
-            {imageFiles.length > 1 && (
-              <span style={{
-                backgroundColor: '#1890ff',
-                color: 'white',
-                borderRadius: '50%',
-                width: 16,
-                height: 16,
-                fontSize: 10,
-                display: 'flex',
-                alignItems: 'center',
-                justifyContent: 'center'
-              }}>
-                +{imageFiles.length - 1}
-              </span>
-            )}
           </div>
         );
       },
@@ -476,63 +452,6 @@ const PolicyNewsPage: React.FC = () => {
     return file?.fullUrl || file?.url || '';
   };
 
-  // 文件预览组件 - 专门用于显示图片文件
-  const FilePreview = ({ files }: { files: any[] }) => {
-    if (!Array.isArray(files) || files.length === 0) return null;
-    
-    const imageFiles = files.filter(file => file?.type?.startsWith('image/'));
-    if (imageFiles.length === 0) return null;
-    
-    return (
-      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
-        {imageFiles.map((file) => (
-          <div key={file?.id} style={{
-            position: 'relative',
-            width: 80,
-            height: 80,
-            border: '1px solid #d9d9d9',
-            borderRadius: 4,
-            overflow: 'hidden'
-          }}>
-            <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%'
-                  }}>
-                    <EyeOutlined style={{ fontSize: 16 }} />
-                    <span style={{ fontSize: 10, marginTop: 2 }}>预览</span>
-                  </div>
-                )
-              }}
-            />
-            <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'
-            }}>
-              {imageFiles.length > 1 && imageFiles.indexOf(file) + 1}
-            </div>
-          </div>
-        ))}
-      </div>
-    );
-  };
-
   // 富文本编辑器配置
   const quillModules = {
     toolbar: [
@@ -768,12 +687,9 @@ const PolicyNewsPage: React.FC = () => {
                     已选择图片预览:
                   </div>
                   <FilePreview
-                    files={selectedFileIds.map(id => ({
-                      id,
-                      type: 'image/jpeg',
-                      fullUrl: `/api/v1/files/${id}`,
-                      name: `图片${id}`
-                    }))}
+                    fileIds={selectedFileIds}
+                    size="medium"
+                    maxCount={12}
                   />
                 </div>
               )}
@@ -784,7 +700,11 @@ const PolicyNewsPage: React.FC = () => {
                   <div style={{ marginBottom: 8, fontSize: 12, color: '#666' }}>
                     当前已关联图片:
                   </div>
-                  <FilePreview files={editingRecord.files.filter(f => f?.type?.startsWith('image/'))} />
+                  <FilePreview
+                    files={editingRecord.files}
+                    size="medium"
+                    maxCount={12}
+                  />
                 </div>
               )}
             </Space>