Files
mixvideo-v2/apps/desktop/src/components/outfit/ImageUploader.tsx
2025-07-17 19:06:15 +08:00

340 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useCallback, useRef } from 'react';
import {
PhotoIcon,
CloudArrowUpIcon,
XMarkIcon,
EyeIcon
} from '@heroicons/react/24/outline';
import { useNotifications } from '../NotificationSystem';
interface ImageFile {
file: File;
preview: string;
id: string;
}
interface ImageUploaderProps {
onUpload: (files: File[]) => Promise<void>;
isUploading?: boolean;
maxFiles?: number;
maxFileSize?: number; // in MB
acceptedFormats?: string[];
className?: string;
}
const ImageUploader: React.FC<ImageUploaderProps> = ({
onUpload,
isUploading = false,
maxFiles = 5,
maxFileSize = 10,
acceptedFormats = ['image/jpeg', 'image/png', 'image/webp'],
className = ''
}) => {
const [selectedFiles, setSelectedFiles] = useState<ImageFile[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { addNotification } = useNotifications();
// 验证文件
const validateFile = useCallback((file: File): string | null => {
if (!acceptedFormats.includes(file.type)) {
return `不支持的文件类型。请选择 ${acceptedFormats.join(', ')} 格式的图片。`;
}
if (file.size > maxFileSize * 1024 * 1024) {
return `文件大小超过限制。请选择小于 ${maxFileSize}MB 的图片。`;
}
return null;
}, [acceptedFormats, maxFileSize]);
// 处理文件选择
const handleFiles = useCallback((files: FileList) => {
const newFiles: ImageFile[] = [];
const errors: string[] = [];
Array.from(files).forEach((file) => {
const error = validateFile(file);
if (error) {
errors.push(`${file.name}: ${error}`);
return;
}
if (selectedFiles.length + newFiles.length >= maxFiles) {
errors.push(`最多只能选择 ${maxFiles} 个文件`);
return;
}
const id = Math.random().toString(36).substr(2, 9);
const preview = URL.createObjectURL(file);
newFiles.push({ file, preview, id });
});
if (errors.length > 0) {
addNotification({
type: 'error',
title: '文件验证失败',
message: errors.join('\n')
});
}
if (newFiles.length > 0) {
setSelectedFiles(prev => [...prev, ...newFiles]);
}
}, [selectedFiles.length, maxFiles, validateFile, addNotification]);
// 拖拽处理
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFiles(files);
}
}, [handleFiles]);
// 文件选择
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFiles(files);
}
// 清空input值允许重复选择同一文件
e.target.value = '';
}, [handleFiles]);
// 移除文件
const removeFile = useCallback((id: string) => {
setSelectedFiles(prev => {
const updated = prev.filter(f => f.id !== id);
// 清理预览URL
const removed = prev.find(f => f.id === id);
if (removed) {
URL.revokeObjectURL(removed.preview);
}
return updated;
});
}, []);
// 清空所有文件
const clearAll = useCallback(() => {
selectedFiles.forEach(f => URL.revokeObjectURL(f.preview));
setSelectedFiles([]);
}, [selectedFiles]);
// 上传文件
const handleUpload = useCallback(async () => {
if (selectedFiles.length === 0) {
addNotification({
type: 'warning',
title: '请选择文件',
message: '请先选择要上传的图片文件'
});
return;
}
try {
const files = selectedFiles.map(f => f.file);
await onUpload(files);
addNotification({
type: 'success',
title: '上传成功',
message: `成功上传 ${files.length} 个文件`
});
clearAll();
} catch (error) {
addNotification({
type: 'error',
title: '上传失败',
message: error instanceof Error ? error.message : '上传过程中发生错误'
});
}
}, [selectedFiles, onUpload, addNotification, clearAll]);
// 预览图片
const showPreview = useCallback((preview: string) => {
setPreviewImage(preview);
}, []);
// 清理内存
React.useEffect(() => {
return () => {
selectedFiles.forEach(f => URL.revokeObjectURL(f.preview));
};
}, []);
return (
<div className={`space-y-4 ${className}`}>
{/* 拖拽上传区域 */}
<div
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-all duration-300 ${
isDragOver
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-gray-400'
} ${isUploading ? 'opacity-50 pointer-events-none' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptedFormats.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
<div className="space-y-4">
<div className="mx-auto w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
<PhotoIcon className="w-8 h-8 text-gray-400" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
</h3>
<p className="text-gray-500 mb-4">
</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<CloudArrowUpIcon className="w-4 h-4 mr-2" />
</button>
</div>
<div className="text-xs text-gray-400">
{acceptedFormats.map(type => type.split('/')[1].toUpperCase()).join(', ')}
{maxFileSize}MB {maxFiles}
</div>
</div>
</div>
{/* 已选择的文件列表 */}
{selectedFiles.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-900">
{selectedFiles.length}
</h4>
<button
type="button"
onClick={clearAll}
className="text-sm text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{selectedFiles.map((imageFile) => (
<div
key={imageFile.id}
className="relative group bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<div className="aspect-square">
<img
src={imageFile.preview}
alt={imageFile.file.name}
className="w-full h-full object-cover"
/>
</div>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition-all duration-200 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex space-x-2">
<button
type="button"
onClick={() => showPreview(imageFile.preview)}
className="p-2 bg-white rounded-full text-gray-700 hover:text-gray-900"
>
<EyeIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => removeFile(imageFile.id)}
className="p-2 bg-white rounded-full text-red-600 hover:text-red-800"
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-2">
<p className="text-xs text-gray-600 truncate" title={imageFile.file.name}>
{imageFile.file.name}
</p>
<p className="text-xs text-gray-400">
{(imageFile.file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
))}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleUpload}
disabled={isUploading || selectedFiles.length === 0}
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<>
<div className="animate-spin -ml-1 mr-2 h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
...
</>
) : (
<>
<CloudArrowUpIcon className="w-4 h-4 mr-2" />
({selectedFiles.length})
</>
)}
</button>
</div>
</div>
)}
{/* 图片预览模态框 */}
{previewImage && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div className="relative max-w-4xl max-h-full p-4">
<button
type="button"
onClick={() => setPreviewImage(null)}
className="absolute top-4 right-4 p-2 bg-white rounded-full text-gray-700 hover:text-gray-900 z-10"
>
<XMarkIcon className="w-6 h-6" />
</button>
<img
src={previewImage}
alt="预览"
className="max-w-full max-h-full object-contain rounded-lg"
/>
</div>
</div>
)}
</div>
);
};
export default ImageUploader;