1020 lines
37 KiB
TypeScript
1020 lines
37 KiB
TypeScript
/**
|
||
* 任务-详情上传组件
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useRef } from 'react';
|
||
import DetailImageSorter from './DetailImageSorter';
|
||
import UploadForm from './UploadForm';
|
||
import { addCategory, addScene } from './category-handlers';
|
||
|
||
const TASK_DETAIL_SOURCE_FILE_EXTENSIONS = ['psd', 'psb', 'tif', 'tiff', 'zip', 'rar', '7z'] as const;
|
||
const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z';
|
||
const TASK_DETAIL_SOURCE_FILE_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar、.7z';
|
||
|
||
function splitEmptyFiles(files: FileList | File[] | null): {
|
||
validFiles: File[];
|
||
emptyFileNames: string[];
|
||
} {
|
||
if (!files) {
|
||
return {
|
||
validFiles: [],
|
||
emptyFileNames: [],
|
||
};
|
||
}
|
||
|
||
const validFiles: File[] = [];
|
||
const emptyFileNames: string[] = [];
|
||
|
||
Array.from(files).forEach((file) => {
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
if (!file.name?.trim() || !Number.isFinite(file.size) || file.size <= 0) {
|
||
emptyFileNames.push(file.name || '未命名文件');
|
||
return;
|
||
}
|
||
|
||
validFiles.push(file);
|
||
});
|
||
|
||
return {
|
||
validFiles,
|
||
emptyFileNames,
|
||
};
|
||
}
|
||
|
||
interface ProductPackageUploaderProps {
|
||
onSuccess: () => void;
|
||
onCancel: () => void;
|
||
categories: Array<{ id: string; name: string }>;
|
||
scenes: Array<{ id: string; name: string }>;
|
||
platforms: Array<{ id: string; name: string }>;
|
||
productCategories: Array<{ id: string; name: string }>;
|
||
onRefreshCategories: () => Promise<void>;
|
||
onRefreshScenes: () => Promise<void>;
|
||
}
|
||
|
||
interface AssetFile {
|
||
file: File;
|
||
preview: string;
|
||
id: string;
|
||
sortOrder: number;
|
||
skuCode?: string;
|
||
barcodeInput?: string;
|
||
barcodes?: string[];
|
||
}
|
||
|
||
function parseBarcodeInput(input: string): string[] {
|
||
return Array.from(
|
||
new Set(
|
||
String(input || '')
|
||
.split(/[\r\n,,、;;]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
}
|
||
|
||
export default function ProductPackageUploader({
|
||
onSuccess,
|
||
onCancel,
|
||
categories,
|
||
scenes,
|
||
platforms,
|
||
productCategories,
|
||
onRefreshCategories,
|
||
onRefreshScenes,
|
||
}: ProductPackageUploaderProps) {
|
||
// 基本信息
|
||
const [taskNo, setTaskNo] = useState('');
|
||
const [productModel, setProductModel] = useState('');
|
||
const [categoryName, setCategoryName] = useState('');
|
||
const [sceneName, setSceneName] = useState('');
|
||
const [platformName, setPlatformName] = useState('');
|
||
const [productCategoryName, setProductCategoryName] = useState('');
|
||
|
||
// 资产文件
|
||
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
|
||
const [skuImages, setSkuImages] = useState<AssetFile[]>([]);
|
||
const [detailImages, setDetailImages] = useState<AssetFile[]>([]);
|
||
const [psdFiles, setPsdFiles] = useState<AssetFile[]>([]);
|
||
|
||
// 拖拽状态
|
||
const [isMainDragging, setIsMainDragging] = useState(false);
|
||
const [isSkuDragging, setIsSkuDragging] = useState(false);
|
||
const [isDetailDragging, setIsDetailDragging] = useState(false);
|
||
const [isPsdDragging, setIsPsdDragging] = useState(false);
|
||
|
||
// 上传状态
|
||
const [uploading, setUploading] = useState(false);
|
||
const [uploadProgress, setUploadProgress] = useState(0);
|
||
const [processingStep, setProcessingStep] = useState('');
|
||
const [error, setError] = useState('');
|
||
|
||
// 文件输入引用
|
||
const mainInputRef = useRef<HTMLInputElement>(null);
|
||
const skuInputRef = useRef<HTMLInputElement>(null);
|
||
const detailInputRef = useRef<HTMLInputElement>(null);
|
||
const psdInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 验证任务号格式
|
||
// 支持格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
|
||
// 示例:
|
||
// TASK202601030184 - 基础任务号
|
||
// TASK202601071437194808-01 - 一级子任务
|
||
// TASK202601141713525624-01-01 - 二级子任务(任务号-子任务-子任务)
|
||
const validateTaskNo = (value: string): boolean => {
|
||
const regex = /^TASK\d{12,18}(-\d{2})*$/;
|
||
return regex.test(value);
|
||
};
|
||
|
||
// 处理添加新店铺
|
||
const handleAddNewCategory = async (name: string) => {
|
||
await addCategory(
|
||
name,
|
||
async (newCategory) => {
|
||
await onRefreshCategories();
|
||
setCategoryName(newCategory.name);
|
||
},
|
||
(error) => setError(error)
|
||
);
|
||
};
|
||
|
||
// 处理添加新场景
|
||
const handleAddNewScene = async (name: string) => {
|
||
await addScene(
|
||
name,
|
||
async (newScene) => {
|
||
await onRefreshScenes();
|
||
setSceneName(newScene.name);
|
||
},
|
||
(error) => setError(error)
|
||
);
|
||
};
|
||
|
||
// 拖拽处理函数
|
||
const handleDragEnter = (e: React.DragEvent, setter: (value: boolean) => void) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setter(true);
|
||
};
|
||
|
||
const handleDragLeave = (e: React.DragEvent, setter: (value: boolean) => void) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setter(false);
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
};
|
||
|
||
const handleDrop = (
|
||
e: React.DragEvent,
|
||
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>,
|
||
dragSetter: (value: boolean) => void,
|
||
accept: 'image' | 'file' | 'psd'
|
||
) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragSetter(false);
|
||
|
||
const files = Array.from(e.dataTransfer.files);
|
||
if (accept === 'image') {
|
||
const { validFiles, emptyFileNames } = splitEmptyFiles(files);
|
||
const imageFiles = validFiles.filter(f => f.type && f.type.startsWith('image/'));
|
||
if (emptyFileNames.length > 0) {
|
||
setError(`以下文件为空,无法上传:${emptyFileNames.join(', ')}`);
|
||
}
|
||
handleImageSelect(imageFiles, setter);
|
||
} else if (accept === 'psd') {
|
||
// PSD文件拖拽(支持多个文件)
|
||
const psdFiles: File[] = [];
|
||
const invalidFiles: string[] = [];
|
||
const { validFiles, emptyFileNames } = splitEmptyFiles(files);
|
||
|
||
validFiles.forEach((file) => {
|
||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||
if (TASK_DETAIL_SOURCE_FILE_EXTENSIONS.includes((ext || '') as typeof TASK_DETAIL_SOURCE_FILE_EXTENSIONS[number])) {
|
||
psdFiles.push(file);
|
||
} else {
|
||
invalidFiles.push(file.name);
|
||
}
|
||
});
|
||
|
||
const errors: string[] = [];
|
||
if (emptyFileNames.length > 0) {
|
||
errors.push(`以下文件为空,无法上传:${emptyFileNames.join(', ')}`);
|
||
}
|
||
if (invalidFiles.length > 0) {
|
||
errors.push(`以下文件格式不支持:${invalidFiles.join(', ')},请上传 ${TASK_DETAIL_SOURCE_FILE_LABEL} 文件`);
|
||
}
|
||
|
||
if (psdFiles.length > 0) {
|
||
setPsdFiles((prev) => {
|
||
const newFiles: AssetFile[] = psdFiles.map((file, index) => ({
|
||
file,
|
||
preview: '', // PSD文件不需要预览
|
||
id: Math.random().toString(36).substring(2, 11),
|
||
sortOrder: prev.length + index,
|
||
}));
|
||
return [...prev, ...newFiles];
|
||
});
|
||
setError(errors.join(';'));
|
||
} else if (errors.length > 0) {
|
||
setError(errors.join(';'));
|
||
}
|
||
}
|
||
};
|
||
|
||
// 处理图片选择
|
||
const handleImageSelect = (
|
||
files: FileList | File[] | null,
|
||
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>,
|
||
isDetail: boolean = false
|
||
) => {
|
||
if (!files) return;
|
||
|
||
const { validFiles, emptyFileNames } = splitEmptyFiles(files);
|
||
if (emptyFileNames.length > 0) {
|
||
setError(`以下文件为空,无法上传:${emptyFileNames.join(', ')}`);
|
||
} else {
|
||
setError('');
|
||
}
|
||
|
||
setter((prev) => {
|
||
const startOrder = prev.length;
|
||
const newFiles: AssetFile[] = [];
|
||
|
||
validFiles.forEach((file, index) => {
|
||
if (file && file.type && file.type.startsWith('image/')) {
|
||
newFiles.push({
|
||
file,
|
||
preview: URL.createObjectURL(file),
|
||
id: Math.random().toString(36).substring(2, 11),
|
||
sortOrder: startOrder + index,
|
||
});
|
||
}
|
||
});
|
||
|
||
return [...prev, ...newFiles];
|
||
});
|
||
};
|
||
|
||
// 删除图片
|
||
const handleRemoveImage = (
|
||
id: string,
|
||
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>
|
||
) => {
|
||
setter((prev) => {
|
||
const removed = prev.find((item) => item.id === id);
|
||
if (removed) {
|
||
URL.revokeObjectURL(removed.preview);
|
||
}
|
||
return prev.filter((item) => item.id !== id);
|
||
});
|
||
};
|
||
|
||
const updateSkuImageMeta = (id: string, updates: Partial<AssetFile>) => {
|
||
setSkuImages((prev) =>
|
||
prev.map((item) => (item.id === id ? { ...item, ...updates } : item)),
|
||
);
|
||
};
|
||
|
||
// 处理 PSD 文件选择
|
||
const handlePsdSelect = (files: FileList | null) => {
|
||
if (!files || files.length === 0) return;
|
||
|
||
const validFiles: File[] = [];
|
||
const invalidFiles: string[] = [];
|
||
const { validFiles: nonEmptyFiles, emptyFileNames } = splitEmptyFiles(files);
|
||
|
||
nonEmptyFiles.forEach((file) => {
|
||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||
if (TASK_DETAIL_SOURCE_FILE_EXTENSIONS.includes((ext || '') as typeof TASK_DETAIL_SOURCE_FILE_EXTENSIONS[number])) {
|
||
validFiles.push(file);
|
||
} else {
|
||
invalidFiles.push(file.name);
|
||
}
|
||
});
|
||
|
||
const errors: string[] = [];
|
||
if (emptyFileNames.length > 0) {
|
||
errors.push(`以下文件为空,无法上传:${emptyFileNames.join(', ')}`);
|
||
}
|
||
if (invalidFiles.length > 0) {
|
||
errors.push(`以下文件格式不支持:${invalidFiles.join(', ')},请上传 ${TASK_DETAIL_SOURCE_FILE_LABEL} 文件`);
|
||
}
|
||
|
||
if (validFiles.length > 0) {
|
||
setPsdFiles((prev) => {
|
||
const newFiles: AssetFile[] = validFiles.map((file, index) => ({
|
||
file,
|
||
preview: '', // PSD文件不需要预览
|
||
id: Math.random().toString(36).substring(2, 11),
|
||
sortOrder: prev.length + index,
|
||
}));
|
||
return [...prev, ...newFiles];
|
||
});
|
||
setError(errors.join(';'));
|
||
} else if (errors.length > 0) {
|
||
setError(errors.join(';'));
|
||
}
|
||
};
|
||
|
||
// 使用分片上传到本地服务器(和任务-3D原图一样)
|
||
const uploadFileWithChunks = async (
|
||
file: File,
|
||
onProgress?: (progress: number) => void
|
||
): Promise<string> => {
|
||
const { chunkUpload } = await import('@/lib/chunk-upload');
|
||
|
||
const result = await chunkUpload(file, {
|
||
materialType: '任务-详情',
|
||
fileName: file.name,
|
||
}, {
|
||
// chunkSize使用默认配置:内网20MB,公网10MB
|
||
skipComplete: true, // 跳过complete,由后续统一处理
|
||
onProgress: (progress) => {
|
||
onProgress?.(progress);
|
||
},
|
||
});
|
||
|
||
return result.uploadId;
|
||
};
|
||
|
||
const MAX_FILE_CONCURRENCY = 2;
|
||
|
||
const runWithConcurrency = async <TInput, TOutput>(
|
||
items: TInput[],
|
||
limit: number,
|
||
worker: (item: TInput, index: number) => Promise<TOutput>
|
||
): Promise<TOutput[]> => {
|
||
if (items.length === 0) return [];
|
||
|
||
const safeLimit = Math.max(1, Math.min(limit, items.length));
|
||
const results: TOutput[] = new Array(items.length);
|
||
let nextIndex = 0;
|
||
|
||
const consume = async () => {
|
||
while (nextIndex < items.length) {
|
||
const current = nextIndex;
|
||
nextIndex += 1;
|
||
results[current] = await worker(items[current], current);
|
||
}
|
||
};
|
||
|
||
await Promise.all(Array.from({ length: safeLimit }, () => consume()));
|
||
return results;
|
||
};
|
||
|
||
// 提交上传
|
||
const handleSubmit = async () => {
|
||
// 验证必填字段(任务号改为可选,不提示)
|
||
if (!productModel || !categoryName) {
|
||
setError('请填写SPU型号和店铺');
|
||
return;
|
||
}
|
||
|
||
// 如果提供了任务号,验证格式
|
||
if (taskNo && taskNo.trim() && !validateTaskNo(taskNo)) {
|
||
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
|
||
return;
|
||
}
|
||
|
||
// 主图改为非必选,只要至少有一张图片即可
|
||
if (mainImages.length === 0 && skuImages.length === 0 && detailImages.length === 0) {
|
||
setError('请至少上传一张图片(主图、SKU图或详情图)');
|
||
return;
|
||
}
|
||
|
||
let normalizedSkuImages: AssetFile[];
|
||
try {
|
||
normalizedSkuImages = skuImages.map((image, index) => {
|
||
const skuCode = String(image.skuCode || '').trim();
|
||
if (!skuCode) {
|
||
throw new Error(`请为第 ${index + 1} 张 SKU 图填写 SKU 编号`);
|
||
}
|
||
|
||
const barcodes = parseBarcodeInput(image.barcodeInput || '');
|
||
if (barcodes.length === 0) {
|
||
throw new Error(`请为 SKU 图 ${image.file.name} 至少填写 1 个产品条码`);
|
||
}
|
||
|
||
return {
|
||
...image,
|
||
skuCode,
|
||
barcodeInput: barcodes.join('\n'),
|
||
barcodes,
|
||
};
|
||
});
|
||
} catch (validationError) {
|
||
setError(validationError instanceof Error ? validationError.message : 'SKU 条码信息校验失败');
|
||
return;
|
||
}
|
||
|
||
const barcodeOwners = new Map<string, string>();
|
||
for (const image of normalizedSkuImages) {
|
||
for (const barcode of image.barcodes || []) {
|
||
const existingOwner = barcodeOwners.get(barcode);
|
||
if (existingOwner && existingOwner !== image.id) {
|
||
setError(`产品条码 ${barcode} 不能同时绑定两张 SKU 图`);
|
||
return;
|
||
}
|
||
|
||
barcodeOwners.set(barcode, image.id);
|
||
}
|
||
}
|
||
|
||
setUploading(true);
|
||
setUploadProgress(0);
|
||
setProcessingStep('');
|
||
setError('');
|
||
|
||
let createdMaterialId: string | null = null;
|
||
const uploadedFileIds: string[] = [];
|
||
let completed = false;
|
||
|
||
try {
|
||
// 1. 初始化上传
|
||
setProcessingStep('初始化上传...');
|
||
const initResponse = await fetch('/api/materials/upload/product-package/init', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
taskNo,
|
||
productModel,
|
||
categoryName,
|
||
sceneName,
|
||
platformName: platformName || undefined,
|
||
productCategoryName: productCategoryName || undefined,
|
||
}),
|
||
});
|
||
|
||
if (!initResponse.ok) {
|
||
const errorData = await initResponse.json();
|
||
throw new Error(errorData.error || '初始化失败');
|
||
}
|
||
|
||
const { data: initData } = await initResponse.json();
|
||
const materialId = initData.materialId;
|
||
createdMaterialId = materialId;
|
||
|
||
// 2. 上传所有文件(并行上传以提高速度)
|
||
const totalFiles =
|
||
mainImages.length + skuImages.length + detailImages.length + psdFiles.length;
|
||
let uploadedCount = 0;
|
||
|
||
// 进度更新函数(线程安全)
|
||
const updateProgress = () => {
|
||
uploadedCount++;
|
||
setUploadProgress(Math.round((uploadedCount / totalFiles) * 100));
|
||
};
|
||
|
||
// 并行上传资产(使用分片上传)
|
||
const uploadAssets = async (
|
||
assets: AssetFile[],
|
||
assetType: string
|
||
): Promise<Array<{ fileName: string; fileSize: number; uploadId: string; sortOrder: number }>> => {
|
||
if (assets.length === 0) return [];
|
||
|
||
return runWithConcurrency(assets, MAX_FILE_CONCURRENCY, async (asset, index) => {
|
||
try {
|
||
const uploadId = await uploadFileWithChunks(asset.file);
|
||
uploadedFileIds.push(uploadId);
|
||
// 只在上传完成时更新进度
|
||
updateProgress();
|
||
return {
|
||
fileName: asset.file.name,
|
||
fileSize: asset.file.size,
|
||
uploadId,
|
||
sortOrder: index,
|
||
};
|
||
} catch (error) {
|
||
console.error(`[Upload] Failed to upload ${assetType} asset ${index}:`, error);
|
||
throw new Error(`上传${assetType}图片 ${asset.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 并行上传PSD文件(使用分片上传)
|
||
const uploadPsdFiles = async (): Promise<Array<{ fileName: string; fileSize: number; uploadId: string }>> => {
|
||
if (psdFiles.length === 0) return [];
|
||
|
||
return runWithConcurrency(psdFiles, MAX_FILE_CONCURRENCY, async (psdFileItem, index) => {
|
||
try {
|
||
const psdFile = psdFileItem.file;
|
||
const uploadId = await uploadFileWithChunks(psdFile);
|
||
uploadedFileIds.push(uploadId);
|
||
// 只在上传完成时更新进度
|
||
updateProgress();
|
||
return {
|
||
fileName: psdFile.name,
|
||
fileSize: psdFile.size,
|
||
uploadId,
|
||
};
|
||
} catch (error) {
|
||
console.error(`[Upload] Failed to upload PSD file ${index}:`, error);
|
||
throw new Error(`上传PSD文件 ${psdFileItem.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 顺序上传不同资产类型,避免一次性触发过多并发请求导致会话不稳定
|
||
setProcessingStep('上传主图中...');
|
||
const mainAssets = await uploadAssets(mainImages, 'main');
|
||
|
||
setProcessingStep('上传SKU图中...');
|
||
const uploadedSkuAssets = await uploadAssets(normalizedSkuImages, 'sku');
|
||
const skuAssets = uploadedSkuAssets.map((asset, index) => ({
|
||
...asset,
|
||
skuCode: normalizedSkuImages[index].skuCode,
|
||
barcodes: normalizedSkuImages[index].barcodes || [],
|
||
}));
|
||
|
||
setProcessingStep('上传详情图中...');
|
||
const detailAssets = await uploadAssets(detailImages, 'detail');
|
||
|
||
setProcessingStep('上传PSD中...');
|
||
const psdAssetsArray = await uploadPsdFiles();
|
||
|
||
// PSD资产格式:支持数组(complete接口支持数组格式)
|
||
const psdAssets = psdAssetsArray.length > 0 ? psdAssetsArray : undefined;
|
||
|
||
// 3. 完成上传(传递uploadId,后端会处理文件)
|
||
setProcessingStep('打包文件中...');
|
||
setUploadProgress(95);
|
||
const completeResponse = await fetch('/api/materials/upload/product-package/complete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
materialId,
|
||
taskNo, // 传递taskNo,上传完成后写入数据库
|
||
assets: {
|
||
main: mainAssets,
|
||
sku: skuAssets,
|
||
detail: detailAssets,
|
||
psd: psdAssets, // 数组格式(complete接口支持)
|
||
},
|
||
}),
|
||
});
|
||
|
||
if (!completeResponse.ok) {
|
||
const errorData = await completeResponse.json().catch(() => ({}));
|
||
const errorMessage = errorData.error || '完成上传失败';
|
||
console.error('[Upload] Complete failed:', errorMessage, errorData);
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
setProcessingStep('生成预览图中...');
|
||
|
||
// 短暂延迟以显示处理完成状态
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
|
||
setUploadProgress(100);
|
||
setProcessingStep('完成!');
|
||
completed = true;
|
||
|
||
// 清理预览URL
|
||
const cleanup = () => {
|
||
mainImages.forEach((img) => {
|
||
URL.revokeObjectURL(img.preview);
|
||
});
|
||
skuImages.forEach((img) => {
|
||
URL.revokeObjectURL(img.preview);
|
||
});
|
||
detailImages.forEach((img) => {
|
||
URL.revokeObjectURL(img.preview);
|
||
});
|
||
psdFiles.forEach((img) => {
|
||
URL.revokeObjectURL(img.preview);
|
||
});
|
||
};
|
||
cleanup();
|
||
|
||
setTimeout(() => {
|
||
setProcessingStep('');
|
||
onSuccess();
|
||
}, 500);
|
||
} catch (error: unknown) {
|
||
console.error('上传失败:', error);
|
||
|
||
if (createdMaterialId && !completed) {
|
||
try {
|
||
await fetch('/api/materials/upload/product-package/abort', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
materialId: createdMaterialId,
|
||
uploadIds: uploadedFileIds,
|
||
}),
|
||
});
|
||
} catch (abortError) {
|
||
console.error('[Upload] 失败回滚未完成素材失败:', abortError);
|
||
}
|
||
}
|
||
|
||
const { parseApiError } = await import('@/lib/error-handler');
|
||
const { message } = parseApiError(error);
|
||
setError(message);
|
||
setProcessingStep('');
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
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>
|
||
|
||
{/* 使用 UploadForm 组件显示 SPU、店铺、场景字段 */}
|
||
<UploadForm
|
||
materialType="任务-详情"
|
||
productModel={productModel}
|
||
categoryName={categoryName}
|
||
sceneName={sceneName}
|
||
platformName={platformName}
|
||
productCategoryName={productCategoryName}
|
||
renderer=""
|
||
categories={categories}
|
||
scenes={scenes}
|
||
platforms={platforms}
|
||
productCategories={productCategories}
|
||
isBatchMode={false}
|
||
onProductModelChange={setProductModel}
|
||
onCategoryChange={setCategoryName}
|
||
onSceneChange={setSceneName}
|
||
onPlatformChange={setPlatformName}
|
||
onProductCategoryChange={setProductCategoryName}
|
||
onRendererChange={() => {}}
|
||
onAddCategory={handleAddNewCategory}
|
||
onAddScene={handleAddNewScene}
|
||
/>
|
||
|
||
<div className="rounded-apple border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
|
||
系统会按素材实际内容自动判定是否整套:同时拥有主图、SKU 图、详情图时,自动标记为整套,无需手工选择。
|
||
</div>
|
||
|
||
{/* 主图上传 */}
|
||
<div>
|
||
<label htmlFor="main-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
主图
|
||
</label>
|
||
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
|
||
<div
|
||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||
isMainDragging
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'border-gray-300 dark:border-gray-600'
|
||
}`}
|
||
onDragEnter={(e) => handleDragEnter(e, setIsMainDragging)}
|
||
onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)}
|
||
onDragOver={handleDragOver}
|
||
onDrop={(e) => handleDrop(e, setMainImages, setIsMainDragging, 'image')}
|
||
>
|
||
<input
|
||
id="main-images-input"
|
||
ref={mainInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => handleImageSelect(e.target.files, setMainImages)}
|
||
className="hidden"
|
||
disabled={uploading}
|
||
/>
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{mainImages.map((img) => (
|
||
<div key={img.id} className="relative group">
|
||
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||
<img src={img.preview} alt="" className="w-full h-full object-cover" />
|
||
</div>
|
||
<button
|
||
onClick={() => handleRemoveImage(img.id, setMainImages)}
|
||
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={uploading}
|
||
type="button"
|
||
>
|
||
×
|
||
</button>
|
||
</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">
|
||
<title>添加主图</title>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传。每张 SKU 图都要填写 SKU 编号,并录入至少 1 个产品条码。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* SKU图上传 */}
|
||
<div>
|
||
<label htmlFor="sku-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
SKU图
|
||
</label>
|
||
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
|
||
<div
|
||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||
isSkuDragging
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'border-gray-300 dark:border-gray-600'
|
||
}`}
|
||
onDragEnter={(e) => handleDragEnter(e, setIsSkuDragging)}
|
||
onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)}
|
||
onDragOver={handleDragOver}
|
||
onDrop={(e) => handleDrop(e, setSkuImages, setIsSkuDragging, 'image')}
|
||
>
|
||
<input
|
||
id="sku-images-input"
|
||
ref={skuInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => handleImageSelect(e.target.files, setSkuImages)}
|
||
className="hidden"
|
||
disabled={uploading}
|
||
/>
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{skuImages.map((img) => (
|
||
<div key={img.id} className="relative group space-y-3 rounded-apple border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800/80">
|
||
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||
<img src={img.preview} alt="" className="w-full h-full object-cover" />
|
||
</div>
|
||
<button
|
||
onClick={() => handleRemoveImage(img.id, setSkuImages)}
|
||
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={uploading}
|
||
type="button"
|
||
>
|
||
×
|
||
</button>
|
||
<div className="space-y-2">
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
|
||
SKU 编号
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={img.skuCode || ''}
|
||
onChange={(e) => updateSkuImageMeta(img.id, { skuCode: e.target.value })}
|
||
placeholder="例如 SKU001"
|
||
disabled={uploading}
|
||
className="w-full rounded-apple border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
|
||
产品条码
|
||
</label>
|
||
<textarea
|
||
value={img.barcodeInput || ''}
|
||
onChange={(e) => updateSkuImageMeta(img.id, { barcodeInput: e.target.value })}
|
||
placeholder="每行一个,或用逗号分隔"
|
||
disabled={uploading}
|
||
rows={4}
|
||
className="w-full rounded-apple border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => skuInputRef.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">
|
||
<title>添加SKU图</title>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 详情图上传 */}
|
||
<div>
|
||
<label htmlFor="detail-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
详情图
|
||
</label>
|
||
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
|
||
<div
|
||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||
isDetailDragging
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'border-gray-300 dark:border-gray-600'
|
||
}`}
|
||
onDragEnter={(e) => handleDragEnter(e, setIsDetailDragging)}
|
||
onDragLeave={(e) => handleDragLeave(e, setIsDetailDragging)}
|
||
onDragOver={handleDragOver}
|
||
onDrop={(e) => handleDrop(e, setDetailImages, setIsDetailDragging, 'image')}
|
||
>
|
||
<input
|
||
id="detail-images-input"
|
||
ref={detailInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => handleImageSelect(e.target.files, setDetailImages, true)}
|
||
className="hidden"
|
||
disabled={uploading}
|
||
/>
|
||
{/* 上传按钮 */}
|
||
<div className="flex items-center space-x-4 mb-4">
|
||
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
||
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<title>添加详情图</title>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</div>
|
||
<div className="flex-1">
|
||
<button
|
||
onClick={() => detailInputRef.current?.click()}
|
||
className="btn-secondary"
|
||
disabled={uploading}
|
||
type="button"
|
||
>
|
||
{detailImages.length > 0 ? '继续添加图片' : '选择详情图'}
|
||
</button>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 详情图排序组件 */}
|
||
{detailImages.length > 0 && (
|
||
<DetailImageSorter
|
||
images={detailImages}
|
||
onChange={setDetailImages}
|
||
onRemove={(id) => handleRemoveImage(id, setDetailImages)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* PSD上传 */}
|
||
<div>
|
||
<label htmlFor="psd-files-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
PSD源文件(可选)
|
||
</label>
|
||
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
|
||
<div
|
||
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
|
||
isPsdDragging
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'border-gray-300 dark:border-gray-600'
|
||
}`}
|
||
onDragEnter={(e) => handleDragEnter(e, setIsPsdDragging)}
|
||
onDragLeave={(e) => handleDragLeave(e, setIsPsdDragging)}
|
||
onDragOver={handleDragOver}
|
||
onDrop={(e) => {
|
||
handleDrop(e, setDetailImages, setIsPsdDragging, 'psd');
|
||
}}
|
||
>
|
||
<input
|
||
id="psd-files-input"
|
||
ref={psdInputRef}
|
||
type="file"
|
||
accept={TASK_DETAIL_SOURCE_FILE_ACCEPT}
|
||
multiple
|
||
onChange={(e) => handlePsdSelect(e.target.files)}
|
||
className="hidden"
|
||
disabled={uploading}
|
||
/>
|
||
{/* 上传按钮 */}
|
||
<div className="flex items-center space-x-4">
|
||
{psdFiles.length > 0 ? (
|
||
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
||
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<title>PSD文件</title>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||
</svg>
|
||
<div className="absolute bottom-1 right-1 bg-blue-500 text-white text-xs rounded-full w-6 h-6 flex items-center justify-center">
|
||
{psdFiles.length}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
||
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<title>添加PSD文件</title>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
<div className="flex-1">
|
||
<button
|
||
onClick={() => psdInputRef.current?.click()}
|
||
className="btn-secondary"
|
||
disabled={uploading}
|
||
type="button"
|
||
>
|
||
{psdFiles.length > 0 ? '继续添加PSD文件' : '选择PSD文件'}
|
||
</button>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
支持 {TASK_DETAIL_SOURCE_FILE_LABEL} 格式,可一次选择多个或拖拽上传
|
||
</p>
|
||
{psdFiles.length > 0 && (
|
||
<div className="mt-2 space-y-2">
|
||
{psdFiles.map((psdFile) => (
|
||
<div key={psdFile.id} className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700 rounded-apple">
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">{psdFile.file.name}</span>
|
||
<button
|
||
onClick={() => setPsdFiles((prev) => prev.filter((f) => f.id !== psdFile.id))}
|
||
className="text-red-500 dark:text-red-400 text-sm hover:text-red-600 dark:hover:text-red-300"
|
||
disabled={uploading}
|
||
type="button"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 上传进度 */}
|
||
{uploading && (
|
||
<div className="space-y-2">
|
||
<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"
|
||
style={{ width: `${uploadProgress}%` }}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-between items-center text-sm">
|
||
<span className="text-gray-700 dark:text-gray-300">{uploadProgress}%</span>
|
||
{processingStep && (
|
||
<span className="text-gray-500 dark:text-gray-400">{processingStep}</span>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|