gongxiangp2/components/upload/ProductPackageUploader.tsx

1097 lines
38 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 { useEffect, useRef, useState } from 'react';
import DetailImageSorter from './DetailImageSorter';
import UploadForm from './UploadForm';
import { addCategory, addScene } from './category-handlers';
import SearchableUserSelect from '@/components/SearchableUserSelect';
import {
TASK_DETAIL_SOURCE_TYPE_OPTIONS,
type TaskDetailSourceType,
} from '@/lib/task-detail-source-type';
import {
hasDragLeftDropZone,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
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 [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
const [taskDetailSourceType, setTaskDetailSourceType] = useState<TaskDetailSourceType | ''>('');
const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState('');
const [users, setUsers] = useState<Array<{ id: string; username: string; displayName: string }>>([]);
const [usersLoading, setUsersLoading] = useState(true);
const [usersLoadError, setUsersLoadError] = 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 [noBarcodes, setNoBarcodes] = 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);
useEffect(() => {
let active = true;
const loadUsers = async () => {
try {
const response = await fetch('/api/users/list');
if (!response.ok) {
throw new Error('人员列表加载失败');
}
const data = await response.json();
if (active) {
setUsers(Array.isArray(data) ? data : []);
setUsersLoadError('');
}
} catch (loadError) {
console.error('[ProductPackageUploader] 加载需求运营人员失败:', loadError);
if (active) {
setUsersLoadError('人员列表加载失败,请刷新后重试');
}
} finally {
if (active) {
setUsersLoading(false);
}
}
};
void loadUsers();
return () => {
active = false;
};
}, []);
// 验证任务号格式
// 支持格式: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) => {
prepareFileDrag(e, uploading);
if (uploading) return;
setter(true);
};
const handleDragLeave = (e: React.DragEvent, setter: (value: boolean) => void) => {
e.preventDefault();
e.stopPropagation();
if (hasDragLeftDropZone(e)) {
setter(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
prepareFileDrag(e, uploading);
};
const handleDrop = (
e: React.DragEvent,
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>,
dragSetter: (value: boolean) => void,
accept: 'image' | 'file' | 'psd'
) => {
prepareFileDrag(e, uploading);
dragSetter(false);
if (uploading) {
setError('文件正在上传,请等待完成后再添加');
return;
}
const files = Array.from(e.dataTransfer.files);
if (accept === 'image') {
handleImageSelect(files, 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[]>>,
) => {
if (!files) return;
if (uploading) {
setError('文件正在上传,请等待完成后再添加');
return;
}
const { validFiles, emptyFileNames } = splitEmptyFiles(files);
const { acceptedFiles, errors } = validateImageFiles(validFiles);
const validationErrors = [...errors];
if (emptyFileNames.length > 0) {
validationErrors.unshift(`以下文件为空,无法上传:${emptyFileNames.join('、')}`);
}
setError(validationErrors.join(';'));
setter((prev) => {
const startOrder = prev.length;
const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({
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;
if (uploading) {
setError('文件正在上传,请等待完成后再添加');
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 (!taskDetailRequesterUserId) {
setError('请选择需求运营人员');
return;
}
// 验证必填字段(任务号改为可选,不提示)
if (!productModel || !categoryName) {
setError('请填写SPU型号和店铺');
return;
}
// 如果提供了任务号,验证格式
if (taskNo && taskNo.trim() && !validateTaskNo(taskNo)) {
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
return;
}
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
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 (!noBarcodes && 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,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel,
categoryName,
sceneName,
platformName: platformName || undefined,
productCategoryName: productCategoryName || undefined,
taskDetailSourceType: taskDetailSourceType || undefined,
taskDetailRequesterUserId,
}),
});
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,上传完成后写入数据库
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
noBarcodes,
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">
<TaskReferenceFields
idPrefix="product-package"
taskNo={taskNo}
onTaskNoChange={setTaskNo}
dingtalkApprovalNo={dingtalkApprovalNo}
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
disabled={uploading}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
需求运营 <span className="text-red-500">*</span>
</label>
<SearchableUserSelect
users={users}
value={taskDetailRequesterUserId}
onChange={(userId) => {
setTaskDetailRequesterUserId(userId);
setError('');
}}
placeholder={usersLoading ? '正在加载人员...' : '搜索发起本次出图需求的运营人员'}
required
disabled={uploading || usersLoading || Boolean(usersLoadError)}
/>
<p className={`mt-1 text-xs ${usersLoadError ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
{usersLoadError || '记录发起本次出图需求的运营人员,与素材上传人可以不同。'}
</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>
<label htmlFor="task-detail-source-type" 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>
<select
id="task-detail-source-type"
value={taskDetailSourceType}
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
disabled={uploading}
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"
>
<option value="">未设置</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
同时上传主图 + SKU 图 + 详情图时自动标记为整套
</p>
{/* 主图上传 */}
<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
aria-disabled={uploading}
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'
}`}
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-5 gap-3">
{mainImages.map((img) => (
<div key={img.id} className="relative group">
<div className="relative w-full h-24 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-1.5 -right-1.5 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs 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-24 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-8 h-8 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>
</div>
</div>
{/* SKU图上传 */}
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="sku-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
SKU图
</label>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer select-none">
<input
type="checkbox"
checked={noBarcodes}
onChange={(e) => setNoBarcodes(e.target.checked)}
disabled={uploading}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
/>
本批素材无条码
</label>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
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-5 gap-3">
{skuImages.map((img) => (
<div key={img.id} className="relative group space-y-1.5 rounded-apple border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-800/80">
<div className="relative w-full h-20 rounded-apple overflow-hidden border 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-1.5 -right-1.5 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
disabled={uploading}
type="button"
>
×
</button>
<input
type="text"
value={img.skuCode || ''}
onChange={(e) => updateSkuImageMeta(img.id, { skuCode: e.target.value })}
placeholder="SKU 编号"
disabled={uploading}
className="w-full rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
{!noBarcodes && (
<textarea
value={img.barcodeInput || ''}
onChange={(e) => updateSkuImageMeta(img.id, { barcodeInput: e.target.value })}
placeholder="产品条码(逗号分隔)"
disabled={uploading}
rows={2}
className="w-full rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
)}
</div>
))}
<button
type="button"
onClick={() => skuInputRef.current?.click()}
disabled={uploading}
className="group w-full h-20 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-8 h-8 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>
</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
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
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)}
className="hidden"
disabled={uploading}
/>
{/* 上传按钮 */}
<div className="flex items-center space-x-3 mb-3">
<button
onClick={() => detailInputRef.current?.click()}
className="btn-secondary text-sm"
disabled={uploading}
type="button"
>
{detailImages.length > 0 ? '继续添加' : '选择详情图'}
</button>
<span className="text-xs text-gray-400">JPG / PNG / WebP,支持拖拽</span>
</div>
{/* 详情图排序组件 */}
{detailImages.length > 0 && (
<DetailImageSorter
images={detailImages}
onChange={setDetailImages}
onRemove={(id) => handleRemoveImage(id, setDetailImages)}
disabled={uploading}
/>
)}
</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
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
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-3">
<button
onClick={() => psdInputRef.current?.click()}
className="btn-secondary text-sm"
disabled={uploading}
type="button"
>
{psdFiles.length > 0 ? `继续添加 (${psdFiles.length}个)` : '选择源文件'}
</button>
<span className="text-xs text-gray-400">{TASK_DETAIL_SOURCE_FILE_LABEL}</span>
{psdFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 ml-2">
{psdFiles.map((psdFile) => (
<span key={psdFile.id} className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs text-gray-700 dark:text-gray-300">
{psdFile.file.name}
<button
onClick={() => setPsdFiles((prev) => prev.filter((f) => f.id !== psdFile.id))}
className="text-red-400 hover:text-red-600"
disabled={uploading}
type="button"
>
×
</button>
</span>
))}
</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 || usersLoading || Boolean(usersLoadError)}
type="button"
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}