Przeglądaj źródła

feat: FileSelector 添加 uploadOnly 模式 (Story 13.16)

- 添加 uploadOnly 属性,支持仅上传模式
- uploadOnly 模式下不显示文件列表,提升性能
- 上传成功后自动选择文件并关闭对话框
- 优化文件匹配逻辑(多重匹配策略)
- 添加完整的单元测试覆盖

解决的问题:
- 残疾人上传资料时弹窗显示慢
- 避免 fullUrl N+1 查询问题
- 减少不必要的文件列表 API 调用

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 1 dzień temu
rodzic
commit
2856665905

+ 1 - 0
allin-packages/disability-person-management-ui/src/components/PhotoUploadField.tsx

@@ -169,6 +169,7 @@ export const PhotoUploadField: React.FC<PhotoUploadFieldProps> = ({
                       placeholder="选择或上传照片"
                       showPreview={true}
                       previewSize="medium"
+                      uploadOnly={true}
                       testId={`photo-upload-${index}`}
                     />
                   </div>

+ 209 - 112
packages/file-management-ui-mt/src/components/FileSelector.tsx

@@ -25,6 +25,20 @@ export interface FileSelectorProps {
   description?: string;
   filterType?: 'image' | 'all' | string;
   allowMultiple?: boolean;
+  /**
+   * 仅上传模式 - 只显示上传区域,不显示现有文件列表
+   * @default false
+   *
+   * 当设置为 true 时:
+   * - 对话框只显示上传区域(MinioUploader)
+   * - 不显示现有文件列表
+   * - 不调用文件列表查询 API(性能优化)
+   * - 上传成功后自动选中该文件并关闭对话框,直接返回 fileId
+   *
+   * 适用场景:需要快速上传文件而不需要从现有文件中选择时,
+   * 如残疾人上传资料时避免加载大量缩略图导致的性能问题
+   */
+  uploadOnly?: boolean;
 }
 
 export const FileSelector: React.FC<FileSelectorProps> = ({
@@ -40,6 +54,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
   description = '上传新文件或从已有文件中选择',
   filterType = 'all',
   allowMultiple = false,
+  uploadOnly = false,
 }) => {
   const [isOpen, setIsOpen] = useState(false);
   const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
@@ -97,7 +112,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
     }
   }, [isOpen, value, currentFiles, allowMultiple]);
 
-  // 获取文件列表
+  // 获取文件列表 - uploadOnly 模式下禁用查询以提高性能
   const { data: filesData, isLoading, refetch } = useQuery({
     queryKey: ['files-for-selection', filterType] as const,
     queryFn: async () => {
@@ -111,7 +126,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
       if (response.status !== 200) throw new Error('获取文件列表失败');
       return response.json();
     },
-    enabled: isOpen,
+    enabled: isOpen && !uploadOnly, // uploadOnly 模式下不执行查询
   });
 
   const files = filesData?.data?.filter((f) => {
@@ -166,9 +181,69 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
     setLocalSelectedFiles(initialSelection);
   };
 
-  const handleUploadSuccess = () => {
-    toast.success('文件上传成功!请从列表中选择新上传的文件');
-    refetch();
+  const handleUploadSuccess = async (_fileKey: string, _fileUrl: string, file: File) => {
+    if (uploadOnly) {
+      // uploadOnly 模式:上传成功后自动获取 fileId 并返回
+      try {
+        // 记录上传开始时间
+        const uploadStartTime = Date.now();
+
+        // 获取刚上传的文件的 ID(通过文件名、大小和时间戳匹配)
+        const response = await fileClientManager.get().index.$get({
+          query: {
+            page: 1,
+            pageSize: 50, // 获取更多结果以提高匹配概率
+          }
+        });
+        if (response.status === 200) {
+          const data = await response.json();
+
+          // 多重匹配策略,提高准确性:
+          // 1. 首先通过文件名和大小匹配
+          // 2. 验证上传时间在最近 30 秒内
+          // 3. 如果有多个匹配,选择最新的
+          const matchCandidates = data.data?.filter((f: FileType) => {
+            // 基础匹配:文件名和大小
+            if (f.name !== file.name || f.size !== file.size) {
+              return false;
+            }
+
+            // 时间验证:上传时间在最近 30 秒内(避免匹配旧文件)
+            const fileUploadTime = new Date(f.uploadTime || '').getTime();
+            const timeDiff = uploadStartTime - fileUploadTime;
+            return timeDiff >= 0 && timeDiff < 30000; // 30 秒内
+          }) || [];
+
+          if (matchCandidates.length > 0) {
+            // 选择最新的文件(按 uploadTime 降序排序)
+            const uploadedFile = matchCandidates.sort((a: FileType, b: FileType) => {
+              const timeA = new Date(a.uploadTime || '').getTime();
+              const timeB = new Date(b.uploadTime || '').getTime();
+              return timeB - timeA; // 降序
+            })[0];
+
+            // 直接返回 fileId 并关闭对话框
+            if (allowMultiple) {
+              onChange?.([uploadedFile.id]);
+            } else {
+              onChange?.(uploadedFile.id);
+            }
+            setIsOpen(false);
+            toast.success('文件上传成功!');
+            return;
+          }
+        }
+        // 如果找不到文件,提示用户
+        toast.error('无法获取上传的文件信息');
+      } catch (error) {
+        console.error('获取上传文件信息失败:', error);
+        toast.error('获取上传文件信息失败');
+      }
+    } else {
+      // 默认模式:提示用户从列表中选择
+      toast.success('文件上传成功!请从列表中选择新上传的文件');
+      refetch();
+    }
   };
 
   const getPreviewSize = () => {
@@ -381,130 +456,152 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
           <DialogHeader>
             <DialogTitle>{title}</DialogTitle>
             <DialogDescription>
-              {description}
+              {uploadOnly ? '请上传文件' : 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-20 flex items-center justify-center">
-                        <MinioUploader
-                          uploadPath={uploadPath}
-                          accept={accept}
-                          maxSize={maxSize}
-                          onUploadSuccess={handleUploadSuccess}
-                          buttonText="上传"
-                          size="minimal"
-                          displayMode="card"
-                          showUploadList={false}
-                        />
+            {/* uploadOnly 模式:只显示上传区域 */}
+            {uploadOnly ? (
+              <div className="flex items-center justify-center py-8">
+                <div className="w-full max-w-md">
+                  <MinioUploader
+                    uploadPath={uploadPath}
+                    accept={accept}
+                    maxSize={maxSize}
+                    onUploadSuccess={handleUploadSuccess}
+                    buttonText="点击或拖拽上传文件"
+                    size="default"
+                    displayMode="full"
+                    showUploadList={true}
+                    uploadListTitle="上传进度"
+                  />
+                </div>
+              </div>
+            ) : (
+              /* 默认模式:显示文件列表 */
+              <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-20 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>
-                    <p className="text-xs text-center mt-1 text-muted-foreground">
-                      上传新文件
-                    </p>
-                  </div>
 
-                  {/* 现有文件列表 */}
-                  {files.map((file) => (
-                    <div
-                      key={file.id}
-                      className={cn(
-                        "relative cursor-pointer transition-all duration-200",
-                        "hover:scale-105"
-                      )}
-                      onClick={() => handleSelectFile(file)}
-                    >
+                    {/* 现有文件列表 */}
+                    {files.map((file) => (
                       <div
+                        key={file.id}
                         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"
+                          "relative cursor-pointer transition-all duration-200",
+                          "hover:scale-105"
                         )}
+                        onClick={() => handleSelectFile(file)}
                       >
-                        {file?.type?.startsWith('image/') ? (
-                          <img
-                            src={file.fullUrl}
-                            alt={file.name}
-                            className="w-full h-full object-cover"
-                          />
-                        ) : (
-                          <div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 p-2">
-                            {file.type && getFileIcon(file.type)}
-                            <p className="text-xs text-center mt-1 truncate max-w-full">
-                              {file.name}
-                            </p>
-                          </div>
-                        )}
+                        <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"
+                          )}
+                        >
+                          {file?.type?.startsWith('image/') ? (
+                            <img
+                              src={file.fullUrl}
+                              alt={file.name}
+                              className="w-full h-full object-cover"
+                            />
+                          ) : (
+                            <div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 p-2">
+                              {file.type && getFileIcon(file.type)}
+                              <p className="text-xs text-center mt-1 truncate max-w-full">
+                                {file.name}
+                              </p>
+                            </div>
+                          )}
 
-                        {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>
-                        )}
+                          {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 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>
-                      </div>
 
-                      <p className="text-xs text-center mt-1 truncate">
-                        {file.name}
-                      </p>
-                    </div>
-                  ))}
-
-                  {/* 空状态 - 当没有文件时显示 */}
-                  {files.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>
+                        <p className="text-xs text-center mt-1 truncate">
+                          {file.name}
+                        </p>
+                      </div>
+                    ))}
+
+                    {/* 空状态 - 当没有文件时显示 */}
+                    {files.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>
+          {/* uploadOnly 模式下不显示确认/取消按钮 */}
+          {!uploadOnly && (
+            <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>
     </>

+ 211 - 113
packages/file-management-ui/src/components/FileSelector.tsx

@@ -27,6 +27,20 @@ export interface FileSelectorProps {
   allowMultiple?: boolean;
   /** 唯一标识符,用于测试时区分多个上传组件 */
   testId?: string;
+  /**
+   * 仅上传模式 - 只显示上传区域,不显示现有文件列表
+   * @default false
+   *
+   * 当设置为 true 时:
+   * - 对话框只显示上传区域(MinioUploader)
+   * - 不显示现有文件列表
+   * - 不调用文件列表查询 API(性能优化)
+   * - 上传成功后自动选中该文件并关闭对话框,直接返回 fileId
+   *
+   * 适用场景:需要快速上传文件而不需要从现有文件中选择时,
+   * 如残疾人上传资料时避免加载大量缩略图导致的性能问题
+   */
+  uploadOnly?: boolean;
 }
 
 export const FileSelector: React.FC<FileSelectorProps> = ({
@@ -43,6 +57,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
   filterType = 'all',
   allowMultiple = false,
   testId,
+  uploadOnly = false,
 }) => {
   const [isOpen, setIsOpen] = useState(false);
   const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
@@ -100,7 +115,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
     }
   }, [isOpen, value, currentFiles, allowMultiple]);
 
-  // 获取文件列表
+  // 获取文件列表 - uploadOnly 模式下禁用查询以提高性能
   const { data: filesData, isLoading, refetch } = useQuery({
     queryKey: ['files-for-selection', filterType] as const,
     queryFn: async () => {
@@ -114,7 +129,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
       if (response.status !== 200) throw new Error('获取文件列表失败');
       return response.json();
     },
-    enabled: isOpen,
+    enabled: isOpen && !uploadOnly, // uploadOnly 模式下不执行查询
   });
 
   const files = filesData?.data?.filter((f) => {
@@ -169,9 +184,69 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
     setLocalSelectedFiles(initialSelection);
   };
 
-  const handleUploadSuccess = () => {
-    toast.success('文件上传成功!请从列表中选择新上传的文件');
-    refetch();
+  const handleUploadSuccess = async (_fileKey: string, _fileUrl: string, file: File) => {
+    if (uploadOnly) {
+      // uploadOnly 模式:上传成功后自动获取 fileId 并返回
+      try {
+        // 记录上传开始时间
+        const uploadStartTime = Date.now();
+
+        // 获取刚上传的文件的 ID(通过文件名、大小和时间戳匹配)
+        const response = await fileClientManager.get().index.$get({
+          query: {
+            page: 1,
+            pageSize: 50, // 获取更多结果以提高匹配概率
+          }
+        });
+        if (response.status === 200) {
+          const data = await response.json();
+
+          // 多重匹配策略,提高准确性:
+          // 1. 首先通过文件名和大小匹配
+          // 2. 验证上传时间在最近 30 秒内
+          // 3. 如果有多个匹配,选择最新的
+          const matchCandidates = data.data?.filter((f: FileType) => {
+            // 基础匹配:文件名和大小
+            if (f.name !== file.name || f.size !== file.size) {
+              return false;
+            }
+
+            // 时间验证:上传时间在最近 30 秒内(避免匹配旧文件)
+            const fileUploadTime = new Date(f.uploadTime || '').getTime();
+            const timeDiff = uploadStartTime - fileUploadTime;
+            return timeDiff >= 0 && timeDiff < 30000; // 30 秒内
+          }) || [];
+
+          if (matchCandidates.length > 0) {
+            // 选择最新的文件(按 uploadTime 降序排序)
+            const uploadedFile = matchCandidates.sort((a: FileType, b: FileType) => {
+              const timeA = new Date(a.uploadTime || '').getTime();
+              const timeB = new Date(b.uploadTime || '').getTime();
+              return timeB - timeA; // 降序
+            })[0];
+
+            // 直接返回 fileId 并关闭对话框
+            if (allowMultiple) {
+              onChange?.([uploadedFile.id]);
+            } else {
+              onChange?.(uploadedFile.id);
+            }
+            setIsOpen(false);
+            toast.success('文件上传成功!');
+            return;
+          }
+        }
+        // 如果找不到文件,提示用户
+        toast.error('无法获取上传的文件信息');
+      } catch (error) {
+        console.error('获取上传文件信息失败:', error);
+        toast.error('获取上传文件信息失败');
+      }
+    } else {
+      // 默认模式:提示用户从列表中选择
+      toast.success('文件上传成功!请从列表中选择新上传的文件');
+      refetch();
+    }
   };
 
   const getPreviewSize = () => {
@@ -384,131 +459,154 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
           <DialogHeader>
             <DialogTitle>{title}</DialogTitle>
             <DialogDescription>
-              {description}
+              {uploadOnly ? '请上传文件' : 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-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-3">
-                  {/* 上传区域 - 作为第一项 */}
-                  <div className="relative cursor-pointer transition-all duration-200 aspect-square max-w-full max-h-48">
-                    <div className="rounded-lg border-2 border-dashed border-gray-300 hover:border-primary transition-colors hover:scale-105 h-full">
-                      <div className="p-2 h-full flex items-center justify-center">
-                        <MinioUploader
-                          uploadPath={uploadPath}
-                          accept={accept}
-                          maxSize={maxSize}
-                          onUploadSuccess={handleUploadSuccess}
-                          buttonText="上传"
-                          size="minimal"
-                          displayMode="card"
-                          showUploadList={false}
-                          testId={testId}
-                        />
+            {/* uploadOnly 模式:只显示上传区域 */}
+            {uploadOnly ? (
+              <div className="flex items-center justify-center py-8">
+                <div className="w-full max-w-md">
+                  <MinioUploader
+                    uploadPath={uploadPath}
+                    accept={accept}
+                    maxSize={maxSize}
+                    onUploadSuccess={handleUploadSuccess}
+                    buttonText="点击或拖拽上传文件"
+                    size="default"
+                    displayMode="full"
+                    showUploadList={true}
+                    uploadListTitle="上传进度"
+                    testId={testId}
+                  />
+                </div>
+              </div>
+            ) : (
+              /* 默认模式:显示文件列表 */
+              <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-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-7 gap-3">
+                    {/* 上传区域 - 作为第一项 */}
+                    <div className="relative cursor-pointer transition-all duration-200 aspect-square max-w-full max-h-48">
+                      <div className="rounded-lg border-2 border-dashed border-gray-300 hover:border-primary transition-colors hover:scale-105 h-full">
+                        <div className="p-2 h-full flex items-center justify-center">
+                          <MinioUploader
+                            uploadPath={uploadPath}
+                            accept={accept}
+                            maxSize={maxSize}
+                            onUploadSuccess={handleUploadSuccess}
+                            buttonText="上传"
+                            size="minimal"
+                            displayMode="card"
+                            showUploadList={false}
+                            testId={testId}
+                          />
+                        </div>
                       </div>
+                      <p className="text-xs text-center mt-1 text-muted-foreground">
+                        上传新文件
+                      </p>
                     </div>
-                    <p className="text-xs text-center mt-1 text-muted-foreground">
-                      上传新文件
-                    </p>
-                  </div>
 
-                  {/* 现有文件列表 */}
-                  {files.map((file) => (
-                    <div
-                      key={file.id}
-                      className={cn(
-                        "relative cursor-pointer transition-all duration-200",
-                        "hover:scale-105"
-                      )}
-                      onClick={() => handleSelectFile(file)}
-                    >
+                    {/* 现有文件列表 */}
+                    {files.map((file) => (
                       <div
+                        key={file.id}
                         className={cn(
-                          "relative rounded-lg overflow-hidden border-2 aspect-square max-w-full max-h-48",
-                          isSelected(file.id)
-                            ? "border-primary ring-2 ring-primary ring-offset-2"
-                            : "border-gray-200 hover:border-primary"
+                          "relative cursor-pointer transition-all duration-200",
+                          "hover:scale-105"
                         )}
+                        onClick={() => handleSelectFile(file)}
                       >
-                        {file?.type?.startsWith('image/') ? (
-                          <img
-                            src={file.fullUrl}
-                            alt={file.name}
-                            className="w-full h-full object-cover"
-                          />
-                        ) : (
-                          <div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 p-2">
-                            {file.type && getFileIcon(file.type)}
-                            <p className="text-xs text-center mt-1 truncate max-w-full">
-                              {file.name}
-                            </p>
-                          </div>
-                        )}
+                        <div
+                          className={cn(
+                            "relative rounded-lg overflow-hidden border-2 aspect-square max-w-full max-h-48",
+                            isSelected(file.id)
+                              ? "border-primary ring-2 ring-primary ring-offset-2"
+                              : "border-gray-200 hover:border-primary"
+                          )}
+                        >
+                          {file?.type?.startsWith('image/') ? (
+                            <img
+                              src={file.fullUrl}
+                              alt={file.name}
+                              className="w-full h-full object-cover"
+                            />
+                          ) : (
+                            <div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 p-2">
+                              {file.type && getFileIcon(file.type)}
+                              <p className="text-xs text-center mt-1 truncate max-w-full">
+                                {file.name}
+                              </p>
+                            </div>
+                          )}
 
-                        {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>
-                        )}
+                          {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 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>
-                      </div>
 
-                      <p className="text-xs text-center mt-1 truncate">
-                        {file.name}
-                      </p>
-                    </div>
-                  ))}
-
-                  {/* 空状态 - 当没有文件时显示 */}
-                  {files.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>
+                        <p className="text-xs text-center mt-1 truncate">
+                          {file.name}
+                        </p>
+                      </div>
+                    ))}
+
+                    {/* 空状态 - 当没有文件时显示 */}
+                    {files.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>
+          {/* uploadOnly 模式下不显示确认/取消按钮 */}
+          {!uploadOnly && (
+            <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>
     </>

+ 213 - 1
packages/file-management-ui/tests/components/FileSelector.test.tsx

@@ -1,6 +1,7 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { render, screen, fireEvent, waitFor } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import React from 'react';
 import FileSelector from '../../src/components/FileSelector';
 
 // 完整的mock响应对象
@@ -48,7 +49,25 @@ vi.mock('../../src/api/fileClient', () => {
 
 // Mock 文件上传组件
 vi.mock('../../src/components/MinioUploader', () => ({
-  default: () => <div data-testid="minio-uploader">MinioUploader</div>,
+  default: ({ onUploadSuccess, testId }: { onUploadSuccess?: (key: string, url: string, file: File) => void; testId?: string }) => {
+    // 提供一个测试辅助方法来触发上传成功
+    const triggerUpload = () => {
+      if (onUploadSuccess) {
+        const testFile = new File(['test'], 'test-upload.jpg', { type: 'image/jpeg' });
+        onUploadSuccess('test-key', 'http://example.com/test.jpg', testFile);
+      }
+    };
+    // 将触发方法挂载到 DOM 元素上供测试调用
+    React.useEffect(() => {
+      if (testId) {
+        const element = document.querySelector(`[data-testid="${testId}"]`);
+        if (element) {
+          (element as any).__triggerUpload = triggerUpload;
+        }
+      }
+    }, [testId, onUploadSuccess]);
+    return React.createElement('div', { 'data-testid': testId || 'minio-uploader' });
+  },
 }));
 
 describe('FileSelector', () => {
@@ -210,4 +229,197 @@ describe('FileSelector', () => {
 
     expect(onChange).toHaveBeenCalledWith(1);
   });
+
+  describe('uploadOnly 模式', () => {
+    it('uploadOnly=true 时不应该调用文件列表查询 API', async () => {
+      const { fileClient } = await import('../../src/api/fileClient');
+      (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
+        data: mockFiles,
+        pagination: { current: 1, pageSize: 50, total: 2 }
+      }));
+
+      renderWithQueryClient(
+        <FileSelector
+          value={null}
+          onChange={() => {}}
+          uploadOnly={true}
+        />
+      );
+
+      const selectButton = screen.getByTestId('file-selector-button');
+      fireEvent.click(selectButton);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
+        // 验证描述文本显示为上传模式
+        expect(screen.getByText('请上传文件')).toBeInTheDocument();
+      });
+
+      // 验证没有调用文件列表 API(因为 uploadOnly=true 禁用了查询)
+      expect(fileClient.index.$get).not.toHaveBeenCalled();
+    });
+
+    it('uploadOnly 模式下对话框应该只显示上传区域', async () => {
+      renderWithQueryClient(
+        <FileSelector
+          value={null}
+          onChange={() => {}}
+          uploadOnly={true}
+        />
+      );
+
+      const selectButton = screen.getByTestId('file-selector-button');
+      fireEvent.click(selectButton);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
+        // 验证上传组件存在
+        expect(screen.getByTestId('minio-uploader')).toBeInTheDocument();
+      });
+
+      // 验证不显示确认/取消按钮
+      expect(screen.queryByText('取消')).not.toBeInTheDocument();
+      expect(screen.queryByText('确认选择')).not.toBeInTheDocument();
+    });
+
+    it('uploadOnly 模式与 allowMultiple 多选模式兼容', async () => {
+      const { fileClient } = await import('../../src/api/fileClient');
+      (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
+        data: mockFiles,
+        pagination: { current: 1, pageSize: 50, total: 2 }
+      }));
+
+      const onChange = vi.fn();
+      renderWithQueryClient(
+        <FileSelector
+          value={null}
+          onChange={onChange}
+          uploadOnly={true}
+          allowMultiple={true}
+        />
+      );
+
+      const selectButton = screen.getByTestId('file-selector-button');
+      fireEvent.click(selectButton);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
+        expect(screen.getByTestId('minio-uploader')).toBeInTheDocument();
+      });
+
+      // 验证没有调用文件列表 API
+      expect(fileClient.index.$get).not.toHaveBeenCalled();
+    });
+
+    it('uploadOnly=false 或未设置时,行为与原组件一致', async () => {
+      const { fileClient } = await import('../../src/api/fileClient');
+      (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
+        data: mockFiles,
+        pagination: { current: 1, pageSize: 50, total: 2 }
+      }));
+
+      renderWithQueryClient(
+        <FileSelector
+          value={null}
+          onChange={() => {}}
+          uploadOnly={false}
+        />
+      );
+
+      const selectButton = screen.getByTestId('file-selector-button');
+      fireEvent.click(selectButton);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
+        // 验证显示默认描述文本
+        expect(screen.getByText('上传新文件或从已有文件中选择')).toBeInTheDocument();
+      });
+
+      // 验证调用了文件列表 API
+      expect(fileClient.index.$get).toHaveBeenCalledWith({
+        query: {
+          page: 1,
+          pageSize: 50,
+        }
+      });
+    });
+
+    it('uploadOnly 模式下不显示文件列表', async () => {
+      renderWithQueryClient(
+        <FileSelector
+          value={null}
+          onChange={() => {}}
+          uploadOnly={true}
+        />
+      );
+
+      const selectButton = screen.getByTestId('file-selector-button');
+      fireEvent.click(selectButton);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
+      });
+
+      // 验证不显示现有文件
+      expect(screen.queryByText('test-image.jpg')).not.toBeInTheDocument();
+      expect(screen.queryByText('test-document.pdf')).not.toBeInTheDocument();
+    });
+
+    it('uploadOnly 模式下上传成功后自动选择文件并关闭对话框', async () => {
+      const { fileClient } = await import('../../src/api/fileClient');
+
+      // Mock 文件列表 API,返回刚上传的文件
+      const uploadedFile = {
+        id: 999,
+        name: 'test-upload.jpg',
+        type: 'image/jpeg',
+        size: 4, // File(['test']) 的 size 是 4
+        fullUrl: 'http://example.com/test-upload.jpg',
+        uploadTime: new Date().toISOString(),
+      };
+
+      (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
+        data: [uploadedFile],
+        pagination: { current: 1, pageSize: 50, total: 1 }
+      }));
+
+      const onChange = vi.fn();
+      renderWithQueryClient(
+        <FileSelector
+          value={null}
+          onChange={onChange}
+          uploadOnly={true}
+          testId="photo-upload-0"
+        />
+      );
+
+      // 打开对话框
+      const selectButton = screen.getByTestId('file-selector-button');
+      fireEvent.click(selectButton);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
+      });
+
+      // 触发上传成功(通过挂载的测试辅助方法)
+      const uploaderElement = document.querySelector('[data-testid="photo-upload-0"]');
+      expect(uploaderElement).toBeInTheDocument();
+      (uploaderElement as any).__triggerUpload();
+
+      // 等待 API 被调用
+      await waitFor(() => {
+        expect(fileClient.index.$get).toHaveBeenCalled();
+      });
+
+      // 验证 onChange 被调用,返回正确的 fileId
+      await waitFor(() => {
+        expect(onChange).toHaveBeenCalledWith(999);
+      });
+
+      // 验证对话框已关闭
+      await waitFor(() => {
+        expect(screen.queryByTestId('file-selector-dialog')).not.toBeInTheDocument();
+      });
+    });
+  });
 });