test_AI_Agent / web /src /components /FileUploadArea.tsx
SarahXia0405's picture
Update web/src/components/FileUploadArea.tsx
384b968 verified
import React, { useRef, useState } from 'react';
import { Button } from './ui/button';
import { Upload, File, X, FileText, Presentation, CloudUpload } from 'lucide-react';
import { Card } from './ui/card';
import { Badge } from './ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
import type { UploadedFile, FileType } from '../App';
interface FileUploadAreaProps {
uploadedFiles: UploadedFile[];
onFileUpload: (files: File[]) => void;
onRemoveFile: (index: number) => void;
onFileTypeChange: (index: number, type: FileType) => void;
// ✅ 新增:真正触发后端上传(App 里实现)
onUploadFile?: (index: number) => void;
onUploadAll?: () => void;
disabled?: boolean;
}
interface PendingFile {
file: File;
type: FileType;
}
export function FileUploadArea({
uploadedFiles,
onFileUpload,
onRemoveFile,
onFileTypeChange,
onUploadFile,
onUploadAll,
disabled = false,
}: FileUploadAreaProps) {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [showTypeDialog, setShowTypeDialog] = useState(false);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files).filter((file) =>
['.pdf', '.docx', '.pptx'].some((ext) => file.name.toLowerCase().endsWith(ext))
);
if (files.length > 0) {
setPendingFiles(files.map((file) => ({ file, type: 'other' as FileType })));
setShowTypeDialog(true);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
setPendingFiles(files.map((file) => ({ file, type: 'other' as FileType })));
setShowTypeDialog(true);
}
e.target.value = '';
};
const handleConfirmUpload = () => {
// 这里只“入库”,不触发后端上传(符合你现在的逻辑)
onFileUpload(pendingFiles.map((pf) => pf.file));
// 把用户在弹窗里选的 type 同步到父组件列表(通过 index 偏移)
const startIndex = uploadedFiles.length;
pendingFiles.forEach((pf, idx) => {
setTimeout(() => {
onFileTypeChange(startIndex + idx, pf.type);
}, 0);
});
setPendingFiles([]);
setShowTypeDialog(false);
};
const handleCancelUpload = () => {
setPendingFiles([]);
setShowTypeDialog(false);
};
const handlePendingFileTypeChange = (index: number, type: FileType) => {
setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
};
const getFileIcon = (filename: string) => {
const lower = filename.toLowerCase();
if (lower.endsWith('.pdf')) return FileText;
if (lower.endsWith('.docx')) return File;
if (lower.endsWith('.pptx')) return Presentation;
return File;
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const hasPendingUploads = uploadedFiles.some((f) => !f.uploaded);
return (
<Card className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="text-sm">Course Materials</h4>
{uploadedFiles.length > 0 && <Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>}
</div>
{/* ✅ Upload All Pending(只在存在 pending 且提供了回调时显示) */}
{uploadedFiles.length > 0 && hasPendingUploads && !!onUploadAll && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-2"
disabled={disabled}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUploadAll();
}}
title="Upload all pending files"
>
<CloudUpload className="h-4 w-4" />
Upload All
</Button>
)}
</div>
{/* Upload Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-4 text-center transition-colors
${isDragging ? 'border-primary bg-accent' : 'border-border'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
onClick={() => !disabled && fileInputRef.current?.click()}
>
<Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-1">{disabled ? 'Please log in to upload' : 'Drop files or click to upload'}</p>
<p className="text-xs text-muted-foreground">.pdf, .docx, .pptx</p>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.docx,.pptx"
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
/>
</div>
{/* Uploaded Files List */}
{uploadedFiles.length > 0 && (
<div className="space-y-3 max-h-64 overflow-y-auto">
{uploadedFiles.map((uploadedFile, index) => {
const Icon = getFileIcon(uploadedFile.file.name);
const isUploaded = !!uploadedFile.uploaded;
return (
<div key={index} className="p-3 bg-muted rounded-md space-y-2">
<div className="flex items-center gap-2 group">
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{uploadedFile.file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(uploadedFile.file.size)}</p>
</div>
{/* ✅ 单文件 Upload(仅未上传时显示 & 必须有回调) */}
{!isUploaded && !!onUploadFile && (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs px-2"
disabled={disabled}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUploadFile(index);
}}
title="Upload this file to backend"
>
Upload
</Button>
)}
{/* ✅ 已上传状态 */}
{isUploaded && (
<Badge variant="secondary" className="text-[10px]">
Uploaded{typeof uploadedFile.uploadedChunks === 'number' ? ` (+${uploadedFile.uploadedChunks})` : ''}
</Badge>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onRemoveFile(index);
}}
title="Remove"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">File Type</label>
<Select value={uploadedFile.type} onValueChange={(value) => onFileTypeChange(index, value as FileType)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="syllabus">Syllabus</SelectItem>
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
<SelectItem value="other">Other Course Document</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
})}
</div>
)}
{/* Type Selection Dialog */}
{showTypeDialog && (
<Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Select File Types</DialogTitle>
<DialogDescription>Please select the type for each file you are uploading.</DialogDescription>
</DialogHeader>
<div className="space-y-3 max-h-64 overflow-y-auto">
{pendingFiles.map((pendingFile, index) => {
const Icon = getFileIcon(pendingFile.file.name);
return (
<div key={index} className="p-3 bg-muted rounded-md space-y-2">
<div className="flex items-center gap-2 group">
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{pendingFile.file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(pendingFile.file.size)}</p>
</div>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">File Type</label>
<Select
value={pendingFile.type}
onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="syllabus">Syllabus</SelectItem>
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
<SelectItem value="other">Other Course Document</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelUpload}>
Cancel
</Button>
<Button onClick={handleConfirmUpload}>Upload</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Card>
);
}