|
@@ -25,6 +25,20 @@ export interface FileSelectorProps {
|
|
|
description?: string;
|
|
description?: string;
|
|
|
filterType?: 'image' | 'all' | string;
|
|
filterType?: 'image' | 'all' | string;
|
|
|
allowMultiple?: boolean;
|
|
allowMultiple?: boolean;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 仅上传模式 - 只显示上传区域,不显示现有文件列表
|
|
|
|
|
+ * @default false
|
|
|
|
|
+ *
|
|
|
|
|
+ * 当设置为 true 时:
|
|
|
|
|
+ * - 对话框只显示上传区域(MinioUploader)
|
|
|
|
|
+ * - 不显示现有文件列表
|
|
|
|
|
+ * - 不调用文件列表查询 API(性能优化)
|
|
|
|
|
+ * - 上传成功后自动选中该文件并关闭对话框,直接返回 fileId
|
|
|
|
|
+ *
|
|
|
|
|
+ * 适用场景:需要快速上传文件而不需要从现有文件中选择时,
|
|
|
|
|
+ * 如残疾人上传资料时避免加载大量缩略图导致的性能问题
|
|
|
|
|
+ */
|
|
|
|
|
+ uploadOnly?: boolean;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
@@ -40,6 +54,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
|
description = '上传新文件或从已有文件中选择',
|
|
description = '上传新文件或从已有文件中选择',
|
|
|
filterType = 'all',
|
|
filterType = 'all',
|
|
|
allowMultiple = false,
|
|
allowMultiple = false,
|
|
|
|
|
+ uploadOnly = false,
|
|
|
}) => {
|
|
}) => {
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
|
|
const [selectedFile, setSelectedFile] = useState<FileType | null>(null);
|
|
@@ -97,7 +112,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
|
}
|
|
}
|
|
|
}, [isOpen, value, currentFiles, allowMultiple]);
|
|
}, [isOpen, value, currentFiles, allowMultiple]);
|
|
|
|
|
|
|
|
- // 获取文件列表
|
|
|
|
|
|
|
+ // 获取文件列表 - uploadOnly 模式下禁用查询以提高性能
|
|
|
const { data: filesData, isLoading, refetch } = useQuery({
|
|
const { data: filesData, isLoading, refetch } = useQuery({
|
|
|
queryKey: ['files-for-selection', filterType] as const,
|
|
queryKey: ['files-for-selection', filterType] as const,
|
|
|
queryFn: async () => {
|
|
queryFn: async () => {
|
|
@@ -111,7 +126,7 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
|
if (response.status !== 200) throw new Error('获取文件列表失败');
|
|
if (response.status !== 200) throw new Error('获取文件列表失败');
|
|
|
return response.json();
|
|
return response.json();
|
|
|
},
|
|
},
|
|
|
- enabled: isOpen,
|
|
|
|
|
|
|
+ enabled: isOpen && !uploadOnly, // uploadOnly 模式下不执行查询
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const files = filesData?.data?.filter((f) => {
|
|
const files = filesData?.data?.filter((f) => {
|
|
@@ -166,9 +181,69 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
|
setLocalSelectedFiles(initialSelection);
|
|
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 = () => {
|
|
const getPreviewSize = () => {
|
|
@@ -381,130 +456,152 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
|
<DialogHeader>
|
|
<DialogHeader>
|
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
|
<DialogDescription>
|
|
<DialogDescription>
|
|
|
- {description}
|
|
|
|
|
|
|
+ {uploadOnly ? '请上传文件' : description}
|
|
|
</DialogDescription>
|
|
</DialogDescription>
|
|
|
</DialogHeader>
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
<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>
|
|
</div>
|
|
|
|
|
+ <p className="text-xs text-center mt-1 text-muted-foreground">
|
|
|
|
|
+ 上传新文件
|
|
|
|
|
+ </p>
|
|
|
</div>
|
|
</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
|
|
<div
|
|
|
|
|
+ key={file.id}
|
|
|
className={cn(
|
|
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>
|
|
|
- </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>
|
|
</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>
|
|
</DialogContent>
|
|
|
</Dialog>
|
|
</Dialog>
|
|
|
</>
|
|
</>
|