gongxiangp2/components/upload/ProductPackageUploader.tsx

1020 lines
37 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 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>
);
}