gongxiangp2/components/upload/ThreeDOriginalUploader.tsx.backup

850 lines
31 KiB
Plaintext
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.

'use client'
import { useState, useRef } from 'react';
import Image from 'next/image';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface ThreeDOriginalUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function ThreeDOriginalUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: ThreeDOriginalUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 资产文件(任务-3D原图:预览图、原图)
const [previewImages, setPreviewImages] = useState<AssetFile[]>([]);
const [mainImages, setMainImages] = useState<AssetFile[]>([]); // 原图列表,后端会自动压缩为zip
// 拖拽状态
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
const [isMainDragging, setIsMainDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
const previewInputRef = useRef<HTMLInputElement>(null);
const mainInputRef = useRef<HTMLInputElement>(null); // 原图输入
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
if (!taskNo) return true; // 任务号可选
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(taskNo);
};
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return;
await onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
await onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理图片文件选择
const handleImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = previewImages;
Array.from(files).forEach((file, index) => {
// 只接受图片文件
if (!file.type.startsWith('image/')) {
setError(`${file.name} 不是有效的图片文件`);
return;
}
// 文件大小检查(50MB限制)
if (file.size > 50 * 1024 * 1024) {
setError(`${file.name} 超过50MB,请选择较小的文件`);
return;
}
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + index,
});
});
setPreviewImages([...currentList, ...newFiles]);
setError('');
};
// 处理原图文件选择(可多选)
const handleMainImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = mainImages;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const ext = file.name.toLowerCase();
// 只接受图片文件
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) {
setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`);
continue;
}
// 单个文件大小检查(100MB限制)
if (file.size > 100 * 1024 * 1024) {
setError(`图片 ${file.name} 超过100MB,请选择较小的文件`);
continue;
}
// 创建预览
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + newFiles.length,
});
}
if (newFiles.length > 0) {
setMainImages([...currentList, ...newFiles]);
setError('');
}
};
// 删除预览图
const removePreviewImage = (index: number) => {
const newList = previewImages.filter((_, i) => i !== index);
URL.revokeObjectURL(previewImages[index].preview);
setPreviewImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 删除原图
const removeMainImage = (index: number) => {
const newList = mainImages.filter((_, i) => i !== index);
URL.revokeObjectURL(mainImages[index].preview);
setMainImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 删除ZIP文件函数已移除(不再需要)
// 拖拽处理
const handleDragOver = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(true);
else setIsMainDragging(true);
};
const handleDragLeave = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(false);
else setIsMainDragging(false);
};
const handleDrop = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(false);
else setIsMainDragging(false);
const files = e.dataTransfer.files;
if (type === 'main') {
handleMainImageSelect(files);
} else {
handleImageSelect(files);
}
};
// 上传预览图到S3(压缩)
const uploadPreviewToS3 = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'preview');
const response = await fetch('/api/upload/s3', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `上传预览图失败`);
}
const data = await response.json();
return data.url;
};
// 使用chunk upload上传原图(大文件)
const uploadOriginalWithChunk = async (file: File): Promise<string> => {
const {chunkUpload} = await import('@/lib/chunk-upload');
const metadata = {
materialType: '3D原图',
fileName: file.name,
fileSize: file.size,
};
const result = await chunkUpload(file, metadata, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true, // 不立即complete,等所有文件上传完再统一处理
onProgress: (progress) => {
// 这里可以添加进度回调
console.log(`[Original Upload] ${file.name}: ${progress}%`);
},
});
return result.uploadId; // 返回uploadId,后续用于complete
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
}
if (!categoryName.trim()) {
setError('请选择使用店铺');
return;
}
if (!sceneName.trim()) {
setError('请选择场景类别');
return;
}
// 预览图改为可选,如果没有预览图会使用原图第一张
if (mainImages.length === 0) {
setError('请至少上传1张原图');
return;
}
setUploading(true);
setUploadProgress(0);
try {
// 步骤1: 初始化素材记录(10%)
const initResponse = await fetch('/api/materials/upload/3d-original/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { materialId } = await initResponse.json();
setUploadProgress(10);
// 步骤2: 上传文件(10% -> 80%)
const totalFiles = previewImages.length + mainImages.length;
let uploadedFiles = 0;
const updateProgress = () => {
uploadedFiles++;
const progress = 10 + (uploadedFiles / totalFiles) * 70;
setUploadProgress(Math.round(progress));
};
// 上传预览图(前端压缩后上传到S3)
const uploadPreviewAssets = async (
assets: AssetFile[]
): Promise<Array<{ fileName: string; fileSize: number; fileUrl: string }>> => {
if (assets.length === 0) return [];
// 动态导入图片处理函数
const { compressPreviewImage } = await import('./image-processor');
const uploadPromises = assets.map(async (asset, index) => {
try {
// 1. 前端压缩预览图(防止卡顿)
const compressed = await compressPreviewImage(asset.file);
// 2. 上传压缩后的预览图到S3
const imageUrl = await uploadPreviewToS3(compressed);
updateProgress();
return {
fileName: asset.file.name,
fileSize: asset.file.size,
fileUrl: imageUrl,
sortOrder: index,
};
} catch (error) {
console.error(`[Upload] Failed to upload preview asset ${index}:`, error);
throw new Error(`上传预览图 ${asset.file.name} 失败`);
}
});
return await Promise.all(uploadPromises);
};
// 上传原图(使用chunk上传,不压缩)
const uploadMainAssets = async (
assets: AssetFile[]
): Promise<Array<{ fileName: string; fileSize: number; uploadId: string }>> => {
if (assets.length === 0) return [];
const { chunkUpload } = await import('@/lib/chunk-upload');
const CHUNK_THRESHOLD = 5 * 1024 * 1024; // 5MB
const uploadPromises = assets.map(async (asset, index) => {
try {
// ⚠️ 关键:原图使用chunk upload,保持原始质量
// 大文件走chunk,小文件直接上传
if (asset.file.size >= CHUNK_THRESHOLD) {
const result = await chunkUpload(asset.file, {
materialType: '3D原图',
fileName: asset.file.name,
}, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true, // 先不complete,等所有文件上传完
onProgress: (progress) => {
console.log(`[Original] ${asset.file.name}: ${progress}%`);
updateProgress();
},
});
return {
fileName: asset.file.name,
fileSize: asset.file.size,
uploadId: result.uploadId,
sortOrder: index,
};
} else {
// 小文件也走chunk,确保逻辑统一
const result = await chunkUpload(asset.file, {
materialType: '3D原图',
fileName: asset.file.name,
}, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true,
});
updateProgress();
return {
fileName: asset.file.name,
fileSize: asset.file.size,
uploadId: result.uploadId,
sortOrder: index,
};
}
} catch (error) {
console.error(`[Upload] Failed to upload original asset ${index}:`, error);
throw new Error(`上传原图 ${asset.file.name} 失败`);
}
});
return await Promise.all(uploadPromises);
};
// 并行上传所有资产
const [previewAssets, mainAssets] = await Promise.all([
uploadPreviewAssets(previewImages), // 预览图 - 前端压缩 + S3存储
uploadMainAssets(mainImages), // 原图 - chunk上传到服务器临时目录
]);
setUploadProgress(80);
// 步骤3: 完成上传,后端会处理原图(打包zip + 生成预览图)(80% -> 100%)
const completeResponse = await fetch('/api/materials/upload/3d-original/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
assets: {
preview: previewAssets, // S3预览图URLs
main: mainAssets.map((m, index) => ({
uploadId: m.uploadId,
fileName: m.fileName,
fileSize: m.fileSize,
sortOrder: index, // 使用map的index作为sortOrder
})), // 原图uploadIds,后端会从临时目录读取并打包
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(100);
// 清理预览URL
previewImages.forEach(img => URL.revokeObjectURL(img.preview));
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
// 成功回调
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: any) {
console.error('[3D Original Upload Error]', err);
setError(err.message || '上传失败,请重试');
setUploading(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-6">
{/* 任务号字段 */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184 或 TASK202601071437194808-01"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SPU型号 *
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase();
setProductModel(value);
}}
placeholder="例如:XY-2024-001"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
</div>
{/* 品类分类 */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
)}
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="输入新店铺名称"
disabled={uploading}
/>
<button
type="button"
onClick={handleAddNewCategory}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50"
disabled={uploading}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false);
setNewCategoryName('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={uploading}
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap disabled:opacity-50"
disabled={uploading}
>
+ 新建店铺
</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="输入新场景类别"
disabled={uploading}
/>
<button
type="button"
onClick={handleAddNewScene}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50"
disabled={uploading}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false);
setNewSceneName('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={uploading}
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap disabled:opacity-50"
disabled={uploading}
>
+ 新建场景类别
</button>
</div>
)}
</div>
{/* 平台分类 */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
)}
{/* 预览图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
预览图(可选) <span className="text-gray-500 text-xs">(如不上传,将使用原图第一张作为预览图)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={(e) => handleDragOver(e, 'preview')}
onDragLeave={(e) => handleDragLeave(e, 'preview')}
onDrop={(e) => handleDrop(e, 'preview')}
>
<input
ref={previewInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择预览图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域
</p>
</div>
{/* 预览图列表 */}
{previewImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{previewImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`预览图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removePreviewImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 原图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台会自动压缩为zip)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={(e) => handleDragOver(e, 'main')}
onDragLeave={(e) => handleDragLeave(e, 'main')}
onDrop={(e) => handleDrop(e, 'main')}
>
<input
ref={mainInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleMainImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择原图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域(可多选)
</p>
</div>
{/* 原图列表 */}
{mainImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{mainImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>上传中...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="btn-secondary"
disabled={uploading}
type="button"
>
取消
</button>
<button
onClick={handleSubmit}
className="btn-primary disabled:opacity-50"
disabled={uploading}
type="button"
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}