850 lines
31 KiB
Plaintext
850 lines
31 KiB
Plaintext
'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>
|
||
);
|
||
}
|