gongxiangp2/components/upload/ThreeDOriginalUploader.tsx

534 lines
19 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.

'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('');
// 原图列表(后端会自动压缩为zip并生成预览图)
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
const [isMainDragging, setIsMainDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
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;
onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理原图文件选择(可多选)
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;
}
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 removeMainImage = (index: number) => {
const newList = mainImages.filter((_, i) => i !== index);
URL.revokeObjectURL(mainImages[index].preview);
setMainImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 拖拽处理
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(false);
handleMainImageSelect(e.dataTransfer.files);
};
// 提交表单
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 { chunkUpload } = await import('@/lib/chunk-upload');
const mainAssets: Array<{ uploadId: string; fileName: string; fileSize: number }> = [];
for (let i = 0; i < mainImages.length; i++) {
const asset = mainImages[i];
try {
const result = await chunkUpload(asset.file, {
materialType: '3D原图',
fileName: asset.file.name,
}, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true,
onProgress: (progress) => {
const fileProgress = (i + progress / 100) / mainImages.length;
const totalProgress = 10 + fileProgress * 70;
setUploadProgress(Math.round(totalProgress));
},
});
mainAssets.push({
uploadId: result.uploadId,
fileName: asset.file.name,
fileSize: asset.file.size,
});
} catch (error) {
console.error(`[Upload] Failed to upload original ${i}:`, error);
throw new Error(`上传原图 ${asset.file.name} 失败`);
}
}
setUploadProgress(80);
// 步骤3: 完成上传(80% -> 100%)
// 后端会:1. 打包原图为zip 2. 为每张原图生成预览图
const completeResponse = await fetch('/api/materials/upload/3d-original/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
assets: {
preview: [], // 不传预览图,后端自动生成
main: mainAssets.map((m, index) => ({
uploadId: m.uploadId,
fileName: m.fileName,
fileSize: m.fileSize,
sortOrder: index,
})),
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(100);
// 清理预览URL
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: unknown) {
console.error('[3D Original Upload Error]', err);
setError(err instanceof Error ? 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"
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 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" disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewCategoryInput(false); setNewCategoryName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" 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 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 rounded-apple whitespace-nowrap" 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 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" disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewSceneInput(false); setNewSceneName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" 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 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 rounded-apple whitespace-nowrap" 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 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">(至少1张,支持多选,后台自动生成预览图和压缩包)</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={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<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>
);
}