340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
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; |