Browse Source

✨ feat(file-selector): 实现文件选择器的多选功能

- 添加文件多选支持,通过multiple属性控制单选/多选模式
- 优化文件选择交互,支持选中状态高亮显示
- 实现选择状态管理,添加确认选择按钮和选择提示
- 改进文件项样式,鼠标悬停效果和选中状态视觉区分

♻️ refactor(file-selector): 优化类型定义和代码健壮性

- 使用重载类型定义区分单选/多选模式的props
- 添加类型守卫函数增强类型安全
- 增加文件选择状态重置逻辑,避免模态框复用状态问题
- 优化文件列表过滤逻辑,增加数组类型检查

🐛 fix(policy-news): 修复文件处理相关的类型安全问题

- 增加文件数组类型检查,防止非数组值导致的运行时错误
- 优化file.id获取方式,增加空值过滤,提高代码健壮性
- 修复编辑时文件ID提取的条件判断逻辑
- 为文件预览组件的key添加备选方案,防止id缺失导致的渲染问题
yourname 7 months ago
parent
commit
adc156ba9f
2 changed files with 130 additions and 25 deletions
  1. 119 18
      src/client/admin/components/FileSelector.tsx
  2. 11 7
      src/client/admin/pages/PolicyNewsPage.tsx

+ 119 - 18
src/client/admin/components/FileSelector.tsx

@@ -1,11 +1,12 @@
-import React from 'react';
+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 { File as FileType } from '@/server/modules/files/file.entity';
 import MinioUploader from '@/client/admin/components/MinioUploader';
 
-interface FileSelectorProps {
+// 定义重载的 props 类型
+interface SingleFileSelectorProps {
   visible: boolean;
   onCancel: () => void;
   onSelect: (file: FileType) => void;
@@ -13,8 +14,22 @@ interface FileSelectorProps {
   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,
@@ -22,9 +37,12 @@ const FileSelector: React.FC<FileSelectorProps> = ({
   accept = 'image/*',
   maxSize = 5,
   uploadPath = '/uploads',
-  uploadButtonText = '上传新文件'
+  uploadButtonText = '上传新文件',
+  multiple = false,
+  ...props
 }) => {
   const queryClient = useQueryClient();
+  const [selectedFiles, setSelectedFiles] = useState<FileType[]>([]);
   
   // 获取文件列表
   const { data: filesData, isLoading } = useQuery({
@@ -43,9 +61,47 @@ const FileSelector: React.FC<FileSelectorProps> = ({
     enabled: visible
   });
 
-  const handleSelectFile = (file: any) => {
-    onSelect(file);
-    message.success(`已选择文件: ${file.name}`);
+  // 重置选择状态
+  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) => {
@@ -54,9 +110,20 @@ const FileSelector: React.FC<FileSelectorProps> = ({
     queryClient.invalidateQueries({ queryKey: ['files-for-selection', accept] });
   };
 
-  const filteredFiles = filesData?.data?.filter((f: any) =>
-    !accept || f.type?.startsWith(accept.replace('*', ''))
-  ) || [];
+  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
@@ -64,7 +131,10 @@ const FileSelector: React.FC<FileSelectorProps> = ({
       open={visible}
       onCancel={onCancel}
       width={800}
-      footer={null}
+      onOk={handleConfirm}
+      okText="确认选择"
+      cancelText="取消"
+      okButtonProps={{ disabled: selectedFiles.length === 0 }}
     >
       <div style={{ marginBottom: 16 }}>
         <MinioUploader
@@ -79,33 +149,50 @@ const FileSelector: React.FC<FileSelectorProps> = ({
         </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: any) => (
+          filteredFiles.map((file: FileType) => (
             <div
               key={file.id}
               style={{
                 display: 'flex',
                 alignItems: 'center',
                 padding: '12px',
-                border: '1px solid #f0f0f0',
+                border: `1px solid ${isFileSelected(file) ? '#1890ff' : '#f0f0f0'}`,
                 borderRadius: '4px',
                 marginBottom: '8px',
                 cursor: 'pointer',
-                transition: 'all 0.3s'
+                transition: 'all 0.3s',
+                backgroundColor: isFileSelected(file) ? '#e6f7ff' : ''
               }}
               onClick={() => handleSelectFile(file)}
               onMouseEnter={(e) => {
-                e.currentTarget.style.backgroundColor = '#f6ffed';
-                e.currentTarget.style.borderColor = '#b7eb8f';
+                if (!isFileSelected(file)) {
+                  e.currentTarget.style.backgroundColor = '#f6ffed';
+                  e.currentTarget.style.borderColor = '#b7eb8f';
+                }
               }}
               onMouseLeave={(e) => {
-                e.currentTarget.style.backgroundColor = '';
-                e.currentTarget.style.borderColor = '#f0f0f0';
+                if (!isFileSelected(file)) {
+                  e.currentTarget.style.backgroundColor = '';
+                  e.currentTarget.style.borderColor = '#f0f0f0';
+                }
               }}
             >
               <Image
@@ -120,7 +207,12 @@ const FileSelector: React.FC<FileSelectorProps> = ({
                   ID: {file.id} | 大小: {((file.size || 0) / 1024 / 1024).toFixed(2)}MB
                 </div>
               </div>
-              <Button type="primary" size="small">选择</Button>
+              <Button
+                type={isFileSelected(file) ? 'primary' : 'default'}
+                size="small"
+              >
+                {isFileSelected(file) ? '已选择' : '选择'}
+              </Button>
             </div>
           ))
         ) : (
@@ -133,4 +225,13 @@ const FileSelector: React.FC<FileSelectorProps> = ({
   );
 };
 
+// 类型守卫函数
+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;

+ 11 - 7
src/client/admin/pages/PolicyNewsPage.tsx

@@ -246,7 +246,11 @@ const PolicyNewsPage: React.FC = () => {
 
   // 处理文件选择
   const handleFileSelect = (files: any[]) => {
-    const fileIds = files.map(file => file.id);
+    if (!Array.isArray(files)) {
+      console.error('Expected files to be an array, got:', typeof files);
+      return;
+    }
+    const fileIds = files.map(file => file?.id).filter(Boolean);
     setSelectedFileIds(fileIds);
     setFileSelectorVisible(false);
   };
@@ -262,8 +266,8 @@ const PolicyNewsPage: React.FC = () => {
     setContent(record.newsContent);
     
     // 从关联的files中提取fileIds
-    const fileIds = record.files?.map(file => file.id) || [];
-    setSelectedFileIds(fileIds);
+      const fileIds = Array.isArray(record.files) ? record.files.map(file => file?.id).filter(Boolean) : [];
+      setSelectedFileIds(fileIds);
     
     // 设置表单初始值
     form.setFieldsValue({
@@ -352,8 +356,8 @@ const PolicyNewsPage: React.FC = () => {
       width: 80,
       key: 'files',
       render: (files: any[]) => {
-        if (!files || files.length === 0) return null;
-        const firstImage = files.find(file => file.fileType?.startsWith('image/'));
+        if (!Array.isArray(files) || files.length === 0) return null;
+        const firstImage = files.find(file => file?.fileType?.startsWith('image/'));
         if (!firstImage) return null;
         return (
           <Image
@@ -446,12 +450,12 @@ const PolicyNewsPage: React.FC = () => {
 
   // 文件预览组件
   const FilePreview = ({ files }: { files: any[] }) => {
-    if (!files || files.length === 0) return null;
+    if (!Array.isArray(files) || files.length === 0) return null;
     
     return (
       <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
         {files.map((file) => (
-          <div key={file.id} style={{
+          <div key={file?.id || Math.random()} style={{
             position: 'relative',
             width: 80,
             height: 80,