| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- 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;
|