gongxiangp2/components/upload/RealPhotoUploader.tsx

604 lines
22 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, useEffect } from 'react';
import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface RealPhotoUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function RealPhotoUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: RealPhotoUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [dingtalkApprovalNo, setDingtalkApprovalNo] = 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('');
// SPU 自动识别状态
const [shopAutoFilled, setShopAutoFilled] = useState(false);
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false);
const lastParsedSPU = useRef<string>('');
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
if (productModel === lastParsedSPU.current) return;
lastParsedSPU.current = productModel;
if (!productModel) {
setShopAutoFilled(false);
setCategoryAutoFilled(false);
return;
}
const result = parseSPU(productModel);
// 自动填充店铺
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName);
if (shopExists && categoryName !== result.shopName) {
setCategoryName(result.shopName);
setShopAutoFilled(true);
} else {
setShopAutoFilled(false);
}
} else {
setShopAutoFilled(false);
}
// 自动填充品类
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName);
if (catExists && productCategoryName !== result.categoryName) {
setProductCategoryName(result.categoryName);
setCategoryAutoFilled(true);
} else {
setCategoryAutoFilled(false);
}
} else {
setCategoryAutoFilled(false);
}
}, [productModel, categories, productCategories, categoryName, productCategoryName]);
// 验证任务号格式
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;
if (uploading) {
setError('文件正在上传,请等待完成后再添加图片');
return;
}
const currentList = mainImages;
const { acceptedFiles, errors } = validateImageFiles(files, {
maxBytes: 100 * 1024 * 1024,
});
const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({
file,
preview: URL.createObjectURL(file),
sortOrder: currentList.length + index,
}));
if (newFiles.length > 0) {
setMainImages([...currentList, ...newFiles]);
}
setError(errors.join(';'));
};
// 删除原图
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) => {
prepareFileDrag(e, uploading);
if (uploading) return;
setIsMainDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (hasDragLeftDropZone(e)) {
setIsMainDragging(false);
}
};
const handleDrop = (e: React.DragEvent) => {
prepareFileDrag(e, uploading);
setIsMainDragging(false);
if (uploading) {
setError('文件正在上传,请等待完成后再添加图片');
return;
}
handleMainImageSelect(e.dataTransfer.files);
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
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/real-photo/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || 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: '实拍原图',
fileName: asset.file.name,
}, {
// chunkSize使用默认配置:内网20MB,公网10MB
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%)
const completeResponse = await fetch('/api/materials/upload/real-photo/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
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);
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: unknown) {
console.error('[Real Photo 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">
<TaskReferenceFields
idPrefix="real-photo"
taskNo={taskNo}
onTaskNoChange={setTaskNo}
dingtalkApprovalNo={dingtalkApprovalNo}
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
disabled={uploading}
/>
{/* 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">
品类(可选)
{categoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
setProductCategoryName(e.target.value);
setCategoryAutoFilled(false);
}}
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">
使用店铺 *
{shopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</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);
setShopAutoFilled(false);
}}
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
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择原图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())}
>
<input
ref={mainInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleMainImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<div className="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="h-32 w-full rounded-apple bg-gray-50 object-contain dark:bg-gray-800"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 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>
))}
<button
type="button"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="group w-full h-32 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors disabled:opacity-50"
>
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
支持多选,或拖拽图片到此区域
</p>
</div>
</div>
{/* 错误提示 */}
{error && (
<div role="alert" aria-live="assertive" 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="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<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>
</div>
);
}