|
|
@@ -1,312 +0,0 @@
|
|
|
-import React, { useState } from 'react';
|
|
|
-import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
-import { Button } from '@/client/components/ui/button';
|
|
|
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
|
|
|
-import { Card, CardContent } from '@/client/components/ui/card';
|
|
|
-import { Badge } from '@/client/components/ui/badge';
|
|
|
-import { toast } from 'sonner';
|
|
|
-import { fileClient } from '@/client/api';
|
|
|
-import type { FileType } from '@/server/modules/files/file.schema';
|
|
|
-import MinioUploader from '@/client/admin-shadcn/components/MinioUploader';
|
|
|
-import { Check, Upload } from 'lucide-react';
|
|
|
-
|
|
|
-// 定义重载的 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) {
|
|
|
- toast.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) => {
|
|
|
- toast.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}` : '请选择文件';
|
|
|
- };
|
|
|
-
|
|
|
- // 格式化文件大小
|
|
|
- const formatFileSize = (bytes: number) => {
|
|
|
- if (bytes === 0) return '0 Bytes';
|
|
|
- const k = 1024;
|
|
|
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
|
- const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
|
- };
|
|
|
-
|
|
|
- // 获取文件类型的显示标签
|
|
|
- const getFileTypeBadge = (type: string) => {
|
|
|
- if (type.startsWith('image/')) {
|
|
|
- return { text: '图片', color: 'bg-blue-100 text-blue-800' };
|
|
|
- } else if (type.startsWith('video/')) {
|
|
|
- return { text: '视频', color: 'bg-red-100 text-red-800' };
|
|
|
- } else if (type.startsWith('audio/')) {
|
|
|
- return { text: '音频', color: 'bg-purple-100 text-purple-800' };
|
|
|
- } else if (type.includes('pdf')) {
|
|
|
- return { text: 'PDF', color: 'bg-red-100 text-red-800' };
|
|
|
- } else if (type.includes('word')) {
|
|
|
- return { text: '文档', color: 'bg-blue-100 text-blue-800' };
|
|
|
- } else if (type.includes('excel') || type.includes('sheet')) {
|
|
|
- return { text: '表格', color: 'bg-green-100 text-green-800' };
|
|
|
- } else {
|
|
|
- return { text: '文件', color: 'bg-gray-100 text-gray-800' };
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- return (
|
|
|
- <Dialog open={visible} onOpenChange={onCancel}>
|
|
|
- <DialogContent className="max-w-4xl max-h-[80vh]">
|
|
|
- <DialogHeader>
|
|
|
- <DialogTitle>选择文件</DialogTitle>
|
|
|
- <DialogDescription>
|
|
|
- 从已有文件中选择,或上传新文件
|
|
|
- </DialogDescription>
|
|
|
- </DialogHeader>
|
|
|
-
|
|
|
- <div className="space-y-4">
|
|
|
- {/* 上传区域 */}
|
|
|
- <Card>
|
|
|
- <CardContent className="pt-6">
|
|
|
- <MinioUploader
|
|
|
- uploadPath={uploadPath}
|
|
|
- accept={accept}
|
|
|
- maxSize={maxSize}
|
|
|
- onUploadSuccess={handleUploadSuccess}
|
|
|
- buttonText={uploadButtonText}
|
|
|
- />
|
|
|
- <p className="text-sm text-gray-500 mt-2">
|
|
|
- 上传后请从下方列表中选择文件
|
|
|
- </p>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
-
|
|
|
- {/* 选择状态显示 */}
|
|
|
- <Card>
|
|
|
- <CardContent className="pt-6">
|
|
|
- <div className="bg-green-50 border border-green-200 rounded-md p-3">
|
|
|
- <p className="text-sm text-green-700">{getSelectionText()}</p>
|
|
|
- </div>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
-
|
|
|
- {/* 文件列表 */}
|
|
|
- <div className="space-y-2 max-h-96 overflow-y-auto">
|
|
|
- {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>
|
|
|
- ) : filteredFiles.length > 0 ? (
|
|
|
- <div className="grid gap-3">
|
|
|
- {filteredFiles.map((file) => {
|
|
|
- const typeBadge = getFileTypeBadge(file.type);
|
|
|
- const isSelected = isFileSelected(file);
|
|
|
-
|
|
|
- return (
|
|
|
- <Card
|
|
|
- key={file.id}
|
|
|
- className={`cursor-pointer transition-all hover:shadow-md ${
|
|
|
- isSelected ? 'ring-2 ring-primary' : ''
|
|
|
- }`}
|
|
|
- onClick={() => handleSelectFile(file)}
|
|
|
- >
|
|
|
- <CardContent className="p-4">
|
|
|
- <div className="flex items-center space-x-4">
|
|
|
- {/* 文件预览图 */}
|
|
|
- <div className="relative">
|
|
|
- {file.type.startsWith('image/') ? (
|
|
|
- <img
|
|
|
- src={file.fullUrl}
|
|
|
- alt={file.name}
|
|
|
- className="w-16 h-16 object-cover rounded-md"
|
|
|
- />
|
|
|
- ) : (
|
|
|
- <div className="w-16 h-16 bg-gray-100 rounded-md flex items-center justify-center">
|
|
|
- <Upload className="h-8 w-8 text-gray-400" />
|
|
|
- </div>
|
|
|
- )}
|
|
|
- {isSelected && (
|
|
|
- <div className="absolute -top-2 -right-2 bg-primary text-white rounded-full p-1">
|
|
|
- <Check className="h-3 w-3" />
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* 文件信息 */}
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
- <h4 className="text-sm font-medium truncate">{file.name}</h4>
|
|
|
- <div className="flex items-center space-x-2 mt-1">
|
|
|
- <Badge className={`${typeBadge.color} text-xs`}>
|
|
|
- {typeBadge.text}
|
|
|
- </Badge>
|
|
|
- <span className="text-xs text-gray-500">ID: {file.id}</span>
|
|
|
- <span className="text-xs text-gray-500">
|
|
|
- {formatFileSize(file.size || 0)}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* 选择按钮 */}
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- variant={isSelected ? "default" : "outline"}
|
|
|
- size="sm"
|
|
|
- onClick={(e) => {
|
|
|
- e.stopPropagation();
|
|
|
- handleSelectFile(file);
|
|
|
- }}
|
|
|
- >
|
|
|
- {isSelected ? '已选择' : '选择'}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
- );
|
|
|
- })}
|
|
|
- </div>
|
|
|
- ) : (
|
|
|
- <Card>
|
|
|
- <CardContent className="text-center py-8">
|
|
|
- <Upload className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
|
|
- <p className="text-gray-600">暂无符合条件的文件</p>
|
|
|
- <p className="text-sm text-gray-500 mt-2">请先上传文件或调整筛选条件</p>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <DialogFooter>
|
|
|
- <Button type="button" variant="outline" onClick={onCancel}>
|
|
|
- 取消
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- onClick={handleConfirm}
|
|
|
- disabled={selectedFiles.length === 0}
|
|
|
- >
|
|
|
- 确认选择
|
|
|
- </Button>
|
|
|
- </DialogFooter>
|
|
|
- </DialogContent>
|
|
|
- </Dialog>
|
|
|
- );
|
|
|
-};
|
|
|
-
|
|
|
-// 类型守卫函数
|
|
|
-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;
|