gongxiangp2/components/upload/ProductPackageUploader.tsx

1882 lines
72 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 Select from '@/components/ui/Select'
import { useCallback, useEffect, useRef, useState } from 'react';
import DetailImageSorter from './DetailImageSorter';
import { dropZoneActiveClass, dropZoneIdleClass, inkButtonClass, outlineButtonClass } from './upload-field-styles';
import UploadForm from './UploadForm';
import styles from './ProductPackageUploader.module.css';
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 {
normalizeTaskDetailPrivacyFields,
TASK_DETAIL_DEMAND_TYPE_OPTIONS,
type TaskDetailDemandType,
} from '@/lib/task-detail-privacy-fields';
import {
hasDragLeftDropZone,
handleDropZoneKeyDown,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { detectSkuOrBarcodeName } from '@/lib/sku-barcode-detector';
import {
TASK_DETAIL_UPLOAD_LIMITS,
validateTaskDetailUploadFiles,
} from '@/lib/task-detail-upload-policy';
import {
prepareTaskDetailImagePreview,
prepareTaskDetailSelectionThumbnail,
type TaskDetailAssetKind,
type TaskDetailBrowserPreview,
} from '@/lib/task-detail-image-browser';
import {
isPerformanceSurveyBrowserCapabilityError,
PerformanceSurveyBrowserImageError,
} from '@/lib/performance-survey-image-error';
import {
pollTaskDetailUploadJob,
TaskDetailUploadJobError,
} from '@/lib/task-detail-upload-status';
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';
// 浏览器预处理 worker 的输入格式白名单。不要使用 image/*,它还会放行
// TIFF/HEIC/AVIF/SVG 等当前处理链不支持的类型,避免最后提交时才报错。
const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const;
const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
const TASK_DETAIL_SELECTION_THUMBNAIL_TIMEOUT_MS = 60_000;
const TASK_DETAIL_METADATA_FIELD_CLASS_NAME = 'w-full rounded-[8px] border border-[#e7e8ea] bg-white px-3 py-2 text-[#1a1c1d] outline-none focus:border-[#1a1c1d] focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 disabled:cursor-not-allowed disabled:opacity-60';
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;
previewStatus: 'loading' | 'ready' | 'unavailable';
previewMessage?: string;
id: string;
sortOrder: number;
skuCode?: string;
barcodeInput?: string;
barcodes?: string[];
}
type UploadGroup = 'main' | 'sku' | 'detail' | 'psd';
type ImageUploadGroup = Exclude<UploadGroup, 'psd'>;
type TaskDetailFileSelectionSnapshot = Record<UploadGroup, File[]>;
type TaskDetailFileSelectionLimitKey =
| 'maxImagesPerGroup'
| 'maxTotalImageCount'
| 'maxPsdFileCount'
| 'maxTotalSourceFileCount'
| 'maxTotalSourceBytes'
| 'maxSinglePsdBytes'
| 'maxTotalPsdBytes';
type TaskDetailFileSelectionLimits = Readonly<Record<TaskDetailFileSelectionLimitKey, number>>;
type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes' | 'psdSingle' | 'psdBytes';
const UPLOAD_GROUP_LABELS: Record<UploadGroup, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
psd: 'PSD 源文件',
};
function formatSelectionLimitBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024 / 1024)}GB`;
if (bytes >= 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
return `${bytes}B`;
}
function summarizeRejectedFiles(files: File[]): string {
const names = files.slice(0, 3).map((file) => file.name || '未命名文件');
return files.length > names.length
? `${names.join('、')}等 ${files.length} 个文件`
: names.join('、');
}
export function selectTaskDetailFilesWithinLimits(
group: UploadGroup,
candidates: File[],
existing: TaskDetailFileSelectionSnapshot,
limits: TaskDetailFileSelectionLimits = TASK_DETAIL_UPLOAD_LIMITS,
): { acceptedFiles: File[]; errors: string[] } {
const rejected: Record<SelectionLimitReason, File[]> = {
group: [],
totalImages: [],
totalSources: [],
totalBytes: [],
psdSingle: [],
psdBytes: [],
};
const acceptedFiles: File[] = [];
const isImageGroup = group !== 'psd';
let groupCount = existing[group].length;
let totalImageCount = existing.main.length + existing.sku.length + existing.detail.length;
let totalSourceCount = totalImageCount + existing.psd.length;
let totalImageBytes = [...existing.main, ...existing.sku, ...existing.detail]
.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
let totalPsdBytes = existing.psd
.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
candidates.forEach((file) => {
const groupLimit = isImageGroup ? limits.maxImagesPerGroup : limits.maxPsdFileCount;
let reason: SelectionLimitReason | null = null;
if (groupCount >= groupLimit) {
reason = 'group';
} else if (isImageGroup && totalImageCount >= limits.maxTotalImageCount) {
reason = 'totalImages';
} else if (totalSourceCount >= limits.maxTotalSourceFileCount) {
reason = 'totalSources';
} else if (isImageGroup && totalImageBytes + file.size > limits.maxTotalSourceBytes) {
reason = 'totalBytes';
} else if (!isImageGroup && file.size > limits.maxSinglePsdBytes) {
reason = 'psdSingle';
} else if (!isImageGroup && totalPsdBytes + file.size > limits.maxTotalPsdBytes) {
reason = 'psdBytes';
}
if (reason) {
rejected[reason].push(file);
return;
}
acceptedFiles.push(file);
groupCount += 1;
if (isImageGroup) {
totalImageCount += 1;
totalImageBytes += file.size;
} else {
totalPsdBytes += file.size;
}
totalSourceCount += 1;
});
const errors: string[] = [];
const groupLimit = isImageGroup ? limits.maxImagesPerGroup : limits.maxPsdFileCount;
if (rejected.group.length > 0) {
errors.push(
`${UPLOAD_GROUP_LABELS[group]}最多选择 ${groupLimit} ${isImageGroup ? '张' : '个'},已忽略 ${summarizeRejectedFiles(rejected.group)};请删除不需要的文件,或分批提交`,
);
}
if (rejected.totalImages.length > 0) {
errors.push(
`本次图片合计最多 ${limits.maxTotalImageCount} 张,已忽略 ${summarizeRejectedFiles(rejected.totalImages)};请减少其他分组的图片,或分批提交`,
);
}
if (rejected.totalSources.length > 0) {
errors.push(
`本次原图和源文件合计最多 ${limits.maxTotalSourceFileCount} 个,已忽略 ${summarizeRejectedFiles(rejected.totalSources)};请删除部分已选文件,或分批提交`,
);
}
if (rejected.totalBytes.length > 0) {
errors.push(
`本次图片总大小最多 ${formatSelectionLimitBytes(limits.maxTotalSourceBytes)},已忽略 ${summarizeRejectedFiles(rejected.totalBytes)};请压缩、删除部分图片,或分批提交`,
);
}
if (rejected.psdSingle.length > 0) {
errors.push(
`单个源文件最多 ${formatSelectionLimitBytes(limits.maxSinglePsdBytes)},已忽略 ${summarizeRejectedFiles(rejected.psdSingle)}`,
);
}
if (rejected.psdBytes.length > 0) {
errors.push(
`本次源文件总大小最多 ${formatSelectionLimitBytes(limits.maxTotalPsdBytes)},已忽略 ${summarizeRejectedFiles(rejected.psdBytes)};请分批提交`,
);
}
return { acceptedFiles, errors };
}
type UploadedAsset = {
fileName: string;
fileSize: number;
uploadId: string;
sortOrder: number;
previewAssets?: Array<{
uploadId: string;
fileName: string;
fileSize: number;
width: number;
height: number;
kind: 'preview' | 'slice';
sourceName: string;
sourceIndex: number;
sliceIndex: number;
sliceCount: number;
}>;
previewUploadId?: string;
previewFileName?: string;
previewFileSize?: number;
};
type UploadedTaskResult = {
group: UploadGroup;
asset: UploadedAsset;
};
function parseBarcodeInput(input: string): string[] {
return Array.from(
new Set(
String(input || '')
.split(/[\r\n,,、;;]+/)
.map((item) => item.trim())
.filter(Boolean),
),
);
}
const TRAILING_IMAGE_EXTENSION_PATTERN = /\.(jpe?g|png|webp|gif|bmp|tiff?)$/i;
/**
* 剥掉文件名末尾的图片扩展名,含 .jpg.jpg / .png.png / .jpg.png 等双重、多重后缀。
* 只认图片后缀白名单,条码内部的点号(如 690.123)不会被误伤。
*/
export function stripTrailingImageExtensions(fileName: string): string {
let result = String(fileName || '');
while (TRAILING_IMAGE_EXTENSION_PATTERN.test(result)) {
result = result.replace(TRAILING_IMAGE_EXTENSION_PATTERN, '');
}
return result;
}
// 后台处理阶段的用户话术;未知阶段一律兜底中文,内部 stage 常量绝不直接上屏。
const JOB_STAGE_LABELS: Record<string, string> = {
queued: '后台排队中...',
validating: '正在校验素材信息...',
assembling: '正在合并上传文件...',
previews: '正在生成预览图...',
zips: '正在打包下载文件...',
database: '正在保存素材信息...',
cleanup: '正在清理临时文件...',
completed: '完成!',
failed: '后台处理失败',
}
function AssetThumbnail({
asset,
alt,
}: {
asset: AssetFile;
alt: string;
}) {
if (asset.previewStatus === 'ready' && asset.preview) {
return (
<img
src={asset.preview}
alt={alt}
className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800"
/>
);
}
const isLoading = asset.previewStatus === 'loading';
const statusLabel = isLoading ? '正在生成缩略图' : '预览不可用';
return (
<div
role="img"
aria-label={`${asset.file.name}:${statusLabel}`}
className="flex h-full w-full min-w-0 flex-col items-center justify-center gap-1 bg-gray-50 px-2 text-center dark:bg-gray-800"
>
<svg
aria-hidden="true"
className="h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 5a2 2 0 012-2h8l6 6v10a2 2 0 01-2 2H6a2 2 0 01-2-2V5z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M14 3v6h6M8 15l2-2 2 2 2-3 2 3" />
</svg>
<span className="w-full truncate text-[11px] font-medium text-gray-600 dark:text-gray-300" title={asset.file.name}>
{asset.file.name}
</span>
<span className="text-[10px] leading-tight text-gray-400 dark:text-gray-500">
{asset.previewMessage || statusLabel}
</span>
</div>
);
}
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 [taskDetailDemandType, setTaskDetailDemandType] = useState<TaskDetailDemandType>('新品');
const [taskDetailPrivate, setTaskDetailPrivate] = useState(false);
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);
const errorRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
const cancelledRef = useRef(false);
const uploadStopRef = useRef(false);
const activeXhrsRef = useRef<Set<XMLHttpRequest>>(new Set());
const activeXhrSetsRef = useRef<Map<string, Set<XMLHttpRequest>>>(new Map());
const browserPreviewAbortRef = useRef<AbortController | null>(null);
const jobPollAbortRef = useRef<AbortController | null>(null);
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressRef = useRef(0);
const jobAcceptedRef = useRef(false);
const thumbnailQueueRef = useRef<Promise<void>>(Promise.resolve());
const thumbnailGenerationEpochRef = useRef(0);
const thumbnailAbortRef = useRef<AbortController | null>(null);
const thumbnailActiveAssetIdRef = useRef<string | null>(null);
const thumbnailCancelledAssetIdsRef = useRef<Set<string>>(new Set());
const thumbnailObjectUrlsRef = useRef<Set<string>>(new Set());
const selectionSnapshotRef = useRef<TaskDetailFileSelectionSnapshot>({
main: [],
sku: [],
detail: [],
psd: [],
});
useEffect(() => {
if (error) errorRef.current?.scrollIntoView?.({ block: 'nearest' });
}, [error]);
const abortActiveBrowserPreview = useCallback(() => {
const controller = browserPreviewAbortRef.current;
if (controller) controller.abort();
browserPreviewAbortRef.current = null;
}, []);
const updateAssetPreview = useCallback((
group: ImageUploadGroup,
id: string,
updates: Pick<AssetFile, 'preview' | 'previewStatus' | 'previewMessage'>,
) => {
const applyUpdates = (items: AssetFile[]) => items.map((item) => (
item.id === id ? { ...item, ...updates } : item
));
if (group === 'main') setMainImages(applyUpdates);
else if (group === 'sku') setSkuImages(applyUpdates);
else setDetailImages(applyUpdates);
}, []);
const revokeThumbnailObjectUrl = useCallback((url: string) => {
if (!url || !thumbnailObjectUrlsRef.current.delete(url)) return;
URL.revokeObjectURL(url);
}, []);
const revokeAllThumbnailObjectUrls = useCallback(() => {
thumbnailObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
thumbnailObjectUrlsRef.current.clear();
}, []);
const stopThumbnailGeneration = useCallback((markUnavailable: boolean) => {
thumbnailGenerationEpochRef.current += 1;
thumbnailAbortRef.current?.abort();
thumbnailAbortRef.current = null;
thumbnailActiveAssetIdRef.current = null;
// 旧队列中的任务会因 epoch 不一致而跳过;新一轮选择无需等待旧链收尾。
thumbnailQueueRef.current = Promise.resolve();
if (!markUnavailable || !mountedRef.current) return;
const mark = (items: AssetFile[]) => items.map((item) => (
item.previewStatus === 'loading'
? {
...item,
previewStatus: 'unavailable' as const,
previewMessage: '上传时重新处理',
}
: item
));
setMainImages(mark);
setSkuImages(mark);
setDetailImages(mark);
}, []);
const enqueueSelectionThumbnails = useCallback((
group: ImageUploadGroup,
assets: AssetFile[],
) => {
const epoch = thumbnailGenerationEpochRef.current;
assets.forEach((asset) => {
thumbnailCancelledAssetIdsRef.current.delete(asset.id);
thumbnailQueueRef.current = thumbnailQueueRef.current
.catch(() => undefined)
.then(async () => {
if (
!mountedRef.current
|| epoch !== thumbnailGenerationEpochRef.current
|| thumbnailCancelledAssetIdsRef.current.has(asset.id)
) {
return;
}
const controller = new AbortController();
thumbnailAbortRef.current = controller;
thumbnailActiveAssetIdRef.current = asset.id;
try {
const thumbnail = await prepareTaskDetailSelectionThumbnail(asset.file, {
signal: controller.signal,
timeoutMs: TASK_DETAIL_SELECTION_THUMBNAIL_TIMEOUT_MS,
});
if (
!mountedRef.current
|| epoch !== thumbnailGenerationEpochRef.current
|| thumbnailCancelledAssetIdsRef.current.has(asset.id)
) {
return;
}
const previewUrl = URL.createObjectURL(thumbnail.file);
thumbnailObjectUrlsRef.current.add(previewUrl);
updateAssetPreview(group, asset.id, {
preview: previewUrl,
previewStatus: 'ready',
previewMessage: undefined,
});
} catch (thumbnailError) {
if (
controller.signal.aborted
|| !mountedRef.current
|| epoch !== thumbnailGenerationEpochRef.current
|| thumbnailCancelledAssetIdsRef.current.has(asset.id)
) {
return;
}
updateAssetPreview(group, asset.id, {
preview: '',
previewStatus: 'unavailable',
previewMessage: isPerformanceSurveyBrowserCapabilityError(thumbnailError)
? '浏览器无法生成缩略图'
: '缩略图不可用,提交时校验',
});
} finally {
if (thumbnailAbortRef.current === controller) thumbnailAbortRef.current = null;
if (thumbnailActiveAssetIdRef.current === asset.id) thumbnailActiveAssetIdRef.current = null;
}
});
});
}, [updateAssetPreview]);
const isOptimizationDemand = taskDetailDemandType === '优化';
const taskDetailVisibilityValue = taskDetailPrivate ? 'private' : 'public';
const taskDetailPrivacyHint = isOptimizationDemand
? '私密仅所选需求运营及已授权权限组可访问,上传人不自动放行。'
: '新品固定公开,仅优化需求支持私密;若设为私密,仅所选需求运营及已授权权限组可访问,上传人不自动放行。';
const handleTaskDetailDemandTypeChange = useCallback((value: TaskDetailDemandType) => {
setTaskDetailDemandType(value);
if (value !== '优化') {
setTaskDetailPrivate(false);
}
setError('');
}, []);
const handleTaskDetailVisibilityChange = useCallback((value: 'public' | 'private') => {
setTaskDetailPrivate(value === 'private' && taskDetailDemandType === '优化');
setError('');
}, [taskDetailDemandType]);
useEffect(() => {
// React Strict Mode 在开发环境会执行一次 setup → cleanup → setup。
// 每次 setup 都要恢复挂载标记,避免后续进度和错误状态被误判为已卸载。
mountedRef.current = true;
cancelledRef.current = false;
const activeXhrSets = activeXhrSetsRef.current;
return () => {
mountedRef.current = false;
cancelledRef.current = true;
uploadStopRef.current = true;
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSets.clear();
stopThumbnailGeneration(false);
abortActiveBrowserPreview();
jobPollAbortRef.current?.abort();
if (completionTimerRef.current) clearTimeout(completionTimerRef.current);
revokeAllThumbnailObjectUrls();
};
}, [abortActiveBrowserPreview, revokeAllThumbnailObjectUrls, stopThumbnailGeneration]);
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 getSelectionSnapshot = (): TaskDetailFileSelectionSnapshot => selectionSnapshotRef.current;
const appendSelectedFiles = (group: UploadGroup, files: File[]) => {
if (files.length === 0) return;
const preparedFiles: AssetFile[] = files.map((file) => {
// SKU 图按部门命名习惯自动预填:品类码+纯数字 → SKU 编号框,带 N..V.. 段 → 条码框;
// 判别不出(序号、复合串等)不自动填,由上传人员人工填写。
let autoSkuCode: string | undefined;
let autoBarcodeInput: string | undefined;
if (group === 'sku') {
const detection = detectSkuOrBarcodeName(stripTrailingImageExtensions(file.name));
if (detection?.type === 'skuCode') autoSkuCode = detection.value;
else if (detection?.type === 'barcode') autoBarcodeInput = detection.value;
}
return {
file,
// 原始 File 不进入 img;图片缩略图由单通道 Worker 异步生成。
preview: '',
previewStatus: group === 'psd' ? 'unavailable' : 'loading',
previewMessage: group === 'psd' ? '源文件无需预览' : '正在生成缩略图',
id: Math.random().toString(36).substring(2, 11),
sortOrder: 0,
...(autoSkuCode ? { skuCode: autoSkuCode } : {}),
...(autoBarcodeInput ? { barcodeInput: autoBarcodeInput } : {}),
};
});
selectionSnapshotRef.current = {
...selectionSnapshotRef.current,
[group]: [...selectionSnapshotRef.current[group], ...files],
};
const append = (previous: AssetFile[]) => [
...previous,
...preparedFiles.map((item, index) => ({ ...item, sortOrder: previous.length + index })),
];
if (group === 'main') setMainImages(append);
else if (group === 'sku') setSkuImages(append);
else if (group === 'detail') setDetailImages(append);
else setPsdFiles(append);
if (group !== 'psd') enqueueSelectionThumbnails(group, preparedFiles);
};
const setSelectionFeedback = (
validationErrors: string[],
acceptedCount: number,
unit: '张' | '个',
) => {
const messages = [...validationErrors];
if (messages.length > 0 && acceptedCount > 0) {
messages.push(`其余 ${acceptedCount} ${unit}符合限制的文件已保留,可继续补充或直接提交`);
}
setError(messages.join(';'));
};
const handleDrop = (
e: React.DragEvent,
group: UploadGroup,
dragSetter: (value: boolean) => void,
accept: 'image' | 'psd'
) => {
prepareFileDrag(e, uploading);
dragSetter(false);
if (uploading) {
setError('文件正在上传,请等待完成后再添加');
return;
}
const files = Array.from(e.dataTransfer.files);
if (accept === 'image') {
handleImageSelect(files, group as ImageUploadGroup);
} else if (accept === 'psd') {
handlePsdSelect(files);
}
};
// 处理图片选择
const handleImageSelect = (
files: FileList | File[] | null,
group: ImageUploadGroup,
) => {
if (!files) return;
if (uploading) {
setError('文件正在上传,请等待完成后再添加');
return;
}
const { validFiles, emptyFileNames } = splitEmptyFiles(files);
const { acceptedFiles: formatAcceptedFiles, errors } = validateImageFiles(validFiles, {
allowedExtensions: TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS,
});
const oversizedFiles = formatAcceptedFiles.filter(
(file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes,
);
const singleSizeAcceptedFiles = formatAcceptedFiles.filter(
(file) => file.size <= TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes,
);
const validationErrors = [...errors];
if (emptyFileNames.length > 0) {
validationErrors.unshift(`以下文件为空,无法上传:${emptyFileNames.join('、')}`);
}
if (oversizedFiles.length > 0) {
validationErrors.push(
`以下图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制:${oversizedFiles.map((file) => file.name).join('、')};请先压缩或拆分后重新选择`,
);
}
const selection = selectTaskDetailFilesWithinLimits(
group,
singleSizeAcceptedFiles,
getSelectionSnapshot(),
);
appendSelectedFiles(group, selection.acceptedFiles);
setSelectionFeedback(
[...validationErrors, ...selection.errors],
selection.acceptedFiles.length,
'张',
);
};
// 删除图片
const handleRemoveFile = (id: string, group: UploadGroup) => {
const assets = group === 'main'
? mainImages
: group === 'sku'
? skuImages
: group === 'detail'
? detailImages
: psdFiles;
const removed = assets.find((item) => item.id === id);
if (!removed) return;
thumbnailCancelledAssetIdsRef.current.add(id);
if (thumbnailActiveAssetIdRef.current === id) thumbnailAbortRef.current?.abort();
if (removed.preview) revokeThumbnailObjectUrl(removed.preview);
selectionSnapshotRef.current = {
...selectionSnapshotRef.current,
[group]: selectionSnapshotRef.current[group].filter((file) => file !== removed.file),
};
if (group === 'main') setMainImages((previous) => previous.filter((item) => item.id !== id));
else if (group === 'sku') setSkuImages((previous) => previous.filter((item) => item.id !== id));
else if (group === 'detail') setDetailImages((previous) => previous.filter((item) => item.id !== id));
else setPsdFiles((previous) => previous.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 | File[] | 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} 文件`);
}
const selection = selectTaskDetailFilesWithinLimits(
'psd',
validFiles,
getSelectionSnapshot(),
);
appendSelectedFiles('psd', selection.acceptedFiles);
setSelectionFeedback(
[...errors, ...selection.errors],
selection.acceptedFiles.length,
'个',
);
};
// 使用分片上传到本地服务器(和任务-3D原图一样)。
// 图片预处理和网络上传是两条不同的资源链路:预处理保持单张,网络分片
// 允许两个并发,避免 300M 公网链路被单个请求的往返等待限制吞吐。
const uploadFileWithChunks = async (
file: File,
onProgress?: (progress: number) => void,
isCancelled?: () => boolean,
requestKey?: string,
): Promise<string> => {
const { chunkUpload } = await import('@/lib/chunk-upload');
const result = await chunkUpload(file, {
materialType: '任务-详情',
fileName: file.name,
}, {
// chunkSize使用默认配置:内网20MB,公网10MB
skipComplete: true,
chunkConcurrency: 2,
isCancelled,
onProgress: (progress) => onProgress?.(progress),
onActiveXhrChange: (xhrs) => {
if (requestKey) {
activeXhrSetsRef.current.set(requestKey, xhrs);
const merged = new Set<XMLHttpRequest>();
activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr)));
activeXhrsRef.current = merged;
}
},
});
if (requestKey) {
activeXhrSetsRef.current.delete(requestKey);
const merged = new Set<XMLHttpRequest>();
activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr)));
activeXhrsRef.current = merged;
}
return result.uploadId;
};
// 网络传输允许两个文件同时进行;浏览器预处理在提交函数内另有单槽位,
// 不会同时解码两张大图。服务器端真正的 Sharp/ZIP 处理仍由后台 worker 全局串行。
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;
let stopped = false;
let hasError = false;
let firstError: unknown;
const stopConsumers = (error: unknown) => {
if (hasError) return;
hasError = true;
firstError = error;
stopped = true;
uploadStopRef.current = true;
// 第一处失败出现后立即停止同批网络传输;consumer 仍需全部 settle,
// 这样稍后完成的上传 ID 能进入最终一次素材回滚请求。
abortActiveBrowserPreview();
activeXhrsRef.current.forEach((xhr) => xhr.abort());
};
const consume = async () => {
while (!stopped && nextIndex < items.length) {
const current = nextIndex;
nextIndex += 1;
try {
results[current] = await worker(items[current], current);
} catch (error) {
stopConsumers(error);
return;
}
}
};
await Promise.allSettled(Array.from({ length: safeLimit }, () => consume()));
if (hasError) throw firstError;
return results;
};
// 提交上传
const handleSubmit = async () => {
let taskDetailPrivacyFields: ReturnType<typeof normalizeTaskDetailPrivacyFields>;
try {
taskDetailPrivacyFields = normalizeTaskDetailPrivacyFields({
taskDetailDemandType,
taskDetailPrivate,
});
} catch (validationError) {
setError(validationError instanceof Error ? validationError.message : '任务详情上传选项不合法');
return;
}
if (!taskDetailRequesterUserId) {
setError('请选择需求运营人员');
return;
}
if (!taskDetailSourceType) {
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;
}
const uploadPolicyError = validateTaskDetailUploadFiles({
main: mainImages.map((item) => item.file),
sku: skuImages.map((item) => item.file),
detail: detailImages.map((item) => item.file),
psd: psdFiles.map((item) => item.file),
});
if (uploadPolicyError) {
setError(uploadPolicyError);
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);
}
}
// 提交链会重新生成正式预览。先终止非必要的选图缩略图,避免两条 Worker
// 链同时解码大图;尚未完成的缩略图改为低干扰占位。
stopThumbnailGeneration(true);
setUploading(true);
setUploadProgress(0);
progressRef.current = 0;
setProcessingStep('');
setError('');
cancelledRef.current = false;
uploadStopRef.current = false;
jobAcceptedRef.current = false;
abortActiveBrowserPreview();
jobPollAbortRef.current?.abort();
let createdMaterialId: string | null = null;
const uploadedFileIds: string[] = [];
let completed = false;
let jobAccepted = false;
let serverPreviewFallbackUsed = false;
const setProgress = (nextProgress: number) => {
if (!mountedRef.current) return;
const next = Math.max(progressRef.current, Math.min(100, Math.round(nextProgress)));
progressRef.current = next;
setUploadProgress(next);
};
try {
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,
taskDetailDemandType: taskDetailPrivacyFields.taskDetailDemandType,
taskDetailPrivate: taskDetailPrivacyFields.taskDetailPrivate,
taskDetailRequesterUserId,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { data: initData } = await initResponse.json();
const materialId = initData.materialId;
createdMaterialId = materialId;
const uploadTasks: Array<{
group: UploadGroup;
asset: AssetFile;
sortOrder: number;
skuCode?: string;
barcodes?: string[];
}> = [
...mainImages.map((asset, sortOrder) => ({ group: 'main' as const, asset, sortOrder })),
...normalizedSkuImages.map((asset, sortOrder) => ({
group: 'sku' as const,
asset,
sortOrder,
skuCode: asset.skuCode,
barcodes: asset.barcodes || [],
})),
...detailImages.map((asset, sortOrder) => ({ group: 'detail' as const, asset, sortOrder })),
...psdFiles.map((asset, sortOrder) => ({ group: 'psd' as const, asset, sortOrder })),
];
let totalWorkBytes = uploadTasks.reduce((sum, task) => sum + task.asset.file.size, 0);
let completedWorkBytes = 0;
let reservedPreviewCount = 0;
let reservedPreviewBytes = 0;
const progressByRequest = new Map<string, number>();
const reportFileProgress = (requestKey: string, fileSize: number, progress: number) => {
const uploadedBytes = Math.min(fileSize, Math.max(0, fileSize * progress / 100));
const previousBytes = progressByRequest.get(requestKey) || 0;
if (uploadedBytes <= previousBytes) return;
progressByRequest.set(requestKey, uploadedBytes);
completedWorkBytes += uploadedBytes - previousBytes;
if (totalWorkBytes > 0) setProgress(completedWorkBytes / totalWorkBytes * 90);
};
const fileConcurrency = MAX_FILE_CONCURRENCY;
let browserPreviewBusy = false;
const browserPreviewWaiters: Array<() => void> = [];
const withBrowserPreviewSlot = async <T,>(worker: (signal: AbortSignal) => Promise<T>): Promise<T> => {
if (browserPreviewBusy) {
await new Promise<void>((resolve) => browserPreviewWaiters.push(resolve));
}
browserPreviewBusy = true;
const previewController = new AbortController();
browserPreviewAbortRef.current = previewController;
try {
if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消');
return await worker(previewController.signal);
} finally {
if (browserPreviewAbortRef.current === previewController) {
browserPreviewAbortRef.current = null;
}
browserPreviewBusy = false;
browserPreviewWaiters.shift()?.();
}
};
const uploadTask = async (task: typeof uploadTasks[number], taskIndex: number): Promise<UploadedTaskResult> => {
if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消');
const { group, asset } = task;
const requestPrefix = `${taskIndex}-${asset.id}`;
let previewAssets: NonNullable<UploadedAsset['previewAssets']> = [];
if (group !== 'psd') {
let browserPreviews: TaskDetailBrowserPreview[] = [];
let browserPreviewFailed = false;
try {
browserPreviews = await withBrowserPreviewSlot((signal) =>
prepareTaskDetailImagePreview(
asset.file,
group as TaskDetailAssetKind,
(label) => {
if (mountedRef.current) setProcessingStep(`${asset.file.name}:${label}`);
},
{ signal },
),
);
} catch (previewError) {
if (cancelledRef.current || uploadStopRef.current) throw previewError;
if (!isPerformanceSurveyBrowserCapabilityError(previewError)) throw previewError;
const reason = previewError instanceof Error ? previewError.message : String(previewError);
console.warn(`[Upload] 浏览器预览处理失败,继续上传原图并交由服务器生成预览:${asset.file.name}`, reason);
browserPreviewFailed = true;
serverPreviewFallbackUsed = true;
if (mountedRef.current) {
setProcessingStep(`${asset.file.name}:浏览器预览不可用,继续上传原图,服务器将自动生成预览`);
}
}
if (browserPreviews.length === 0 && !browserPreviewFailed) {
throw new PerformanceSurveyBrowserImageError(
'PROCESSING_FAILED',
`图片「${asset.file.name}」未生成浏览器预览,请重试或重新导出图片`,
);
}
if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消');
const previewBatchBytes = browserPreviews.reduce((sum, preview) => sum + preview.file.size, 0);
if (
reservedPreviewCount + browserPreviews.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount
|| reservedPreviewBytes + previewBatchBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewBytes
) {
throw new Error('本次图片生成的预览切片过多或过大,请减少详情长图数量后分批提交');
}
reservedPreviewCount += browserPreviews.length;
reservedPreviewBytes += previewBatchBytes;
// 预览生成失败只影响优化链路,不应阻断原图上传。完成接口会在缺少
// 客户端预览时进入受限的服务器兜底处理;网络上传错误仍然直接抛出,
// 由外层回滚本次上传,避免把网络错误伪装成浏览器兼容问题。
for (let previewIndex = 0; previewIndex < browserPreviews.length; previewIndex += 1) {
const preview: TaskDetailBrowserPreview = browserPreviews[previewIndex];
totalWorkBytes += preview.file.size;
const previewKey = `${requestPrefix}-preview-${previewIndex}`;
const previewUploadId = await uploadFileWithChunks(
preview.file,
(progress) => reportFileProgress(previewKey, preview.file.size, progress),
() => cancelledRef.current || uploadStopRef.current,
previewKey,
);
uploadedFileIds.push(previewUploadId);
previewAssets.push({
uploadId: previewUploadId,
fileName: preview.file.name,
fileSize: preview.file.size,
width: preview.width,
height: preview.height,
kind: preview.kind,
sourceName: preview.sourceName,
sourceIndex: preview.sourceIndex,
sliceIndex: preview.sliceIndex,
sliceCount: preview.sliceCount,
});
}
}
const originalUploadId = await uploadFileWithChunks(
asset.file,
(progress) => reportFileProgress(`${requestPrefix}-original`, asset.file.size, progress),
() => cancelledRef.current || uploadStopRef.current,
`${requestPrefix}-original`,
);
uploadedFileIds.push(originalUploadId);
return {
group,
asset: {
fileName: asset.file.name,
fileSize: asset.file.size,
uploadId: originalUploadId,
sortOrder: task.sortOrder,
...(previewAssets.length > 0
? {
previewAssets,
previewUploadId: previewAssets[0].uploadId,
previewFileName: previewAssets[0].fileName,
previewFileSize: previewAssets[0].fileSize,
}
: {}),
...(group === 'sku' ? { skuCode: task.skuCode, barcodes: task.barcodes || [] } : {}),
},
};
};
setProcessingStep('浏览器预处理并上传素材中...');
const uploadedResults = await runWithConcurrency(uploadTasks, fileConcurrency, uploadTask);
if (cancelledRef.current) throw new Error('上传已取消');
const sortAssets = (group: UploadGroup) => uploadedResults
.filter((result) => result.group === group)
.sort((left, right) => left.asset.sortOrder - right.asset.sortOrder)
.map((result) => result.asset);
const mainAssets = sortAssets('main');
const skuAssets = sortAssets('sku');
const detailAssets = sortAssets('detail');
const psdAssets = sortAssets('psd');
setProcessingStep('已上传,正在提交后台处理任务...');
setProgress(90);
const completeResponse = await fetch('/api/materials/upload/product-package/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
taskNo,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
noBarcodes,
allowServerPreviewFallback: serverPreviewFallbackUsed,
assets: {
main: mainAssets,
sku: skuAssets,
detail: detailAssets,
psd: psdAssets.length > 0 ? psdAssets : undefined,
},
}),
});
const completePayload = await completeResponse.json().catch(() => ({}));
if (!completeResponse.ok) {
const errorMessage = completePayload.error || '完成上传失败';
console.error('[Upload] Complete failed:', errorMessage, completePayload);
throw new Error(errorMessage);
}
const jobId = completePayload?.data?.jobId;
if (!jobId) {
throw new Error('后台处理任务创建失败,请重试');
}
jobAccepted = true;
jobAcceptedRef.current = true;
// 用户在 complete 请求返回前取消或关闭弹窗时,服务端可能已经接单。
// 此时保留 accepted 状态避免误删任务,但绝不能再创建最长 30 分钟的隐形轮询。
if (cancelledRef.current || !mountedRef.current) {
throw new Error('上传已取消');
}
if (jobId) {
const pollController = new AbortController();
jobPollAbortRef.current = pollController;
await pollTaskDetailUploadJob(jobId, {
signal: pollController.signal,
onStatus: (status) => {
if (!mountedRef.current) return;
const backendProgress = Number.isFinite(status.progress) ? Math.max(0, Math.min(100, status.progress || 0)) : 0;
setProgress(90 + backendProgress * 0.1);
const stageLabel = status.stage ? JOB_STAGE_LABELS[status.stage] : undefined;
setProcessingStep(stageLabel || (status.status === 'queued' ? JOB_STAGE_LABELS.queued : '后台处理中...'));
},
});
}
setProgress(100);
setProcessingStep('完成!');
completed = true;
completionTimerRef.current = setTimeout(() => {
if (!mountedRef.current) return;
setProcessingStep('');
revokeAllThumbnailObjectUrls();
onSuccess();
}, 500);
} catch (error: unknown) {
console.error('上传失败:', error);
if (createdMaterialId && !completed && !jobAccepted) {
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);
}
}
if (cancelledRef.current || (error instanceof Error && error.message === '上传已取消')) {
if (mountedRef.current) {
setError(jobAccepted || jobAcceptedRef.current
? '已停止等待,后台任务仍在服务器处理中,可稍后查看素材状态'
: '已取消上传');
setProcessingStep('');
}
} else {
const { parseApiError } = await import('@/lib/error-handler');
const { message } = parseApiError(error);
if (mountedRef.current) {
const isFailedJob = error instanceof TaskDetailUploadJobError && error.jobStatus === 'failed';
setError(jobAccepted && !isFailedJob ? `文件已上传,后台任务仍在处理:${message}` : message);
setProcessingStep('');
}
}
} finally {
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSetsRef.current.clear();
abortActiveBrowserPreview();
jobPollAbortRef.current = null;
uploadStopRef.current = true;
if (mountedRef.current) setUploading(false);
}
};
const handleCancelUpload = () => {
if (!uploading) {
onCancel();
return;
}
cancelledRef.current = true;
uploadStopRef.current = true;
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSetsRef.current.clear();
abortActiveBrowserPreview();
jobPollAbortRef.current?.abort();
setProcessingStep(jobAcceptedRef.current ? '已停止等待,后台任务继续处理...' : '正在取消上传...');
};
return (
<div className="space-y-5">
<div className={`${styles.metadata} space-y-3`}>
<div className={styles.references}>
<TaskReferenceFields
idPrefix="product-package"
taskNo={taskNo}
onTaskNoChange={setTaskNo}
dingtalkApprovalNo={dingtalkApprovalNo}
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
disabled={uploading}
/>
</div>
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="min-w-0">
<label htmlFor="task-detail-requester" className="mb-1 block text-sm font-medium text-gray-700">
需求运营 <span className="text-red-500">*</span>
</label>
<SearchableUserSelect
id="task-detail-requester"
users={users}
value={taskDetailRequesterUserId}
onChange={(userId) => {
setTaskDetailRequesterUserId(userId);
setError('');
}}
placeholder={usersLoading ? '正在加载人员...' : '搜索发起本次出图需求的运营人员'}
required
disabled={uploading || usersLoading || Boolean(usersLoadError)}
/>
<p className={`mt-1 text-xs leading-5 ${usersLoadError ? 'text-red-500' : 'text-gray-500'}`}>
{usersLoadError || '记录发起本次出图需求的运营人员,与素材上传人可以不同。'}
</p>
</div>
<div className="min-w-0">
<label htmlFor="task-detail-source-type" className="mb-1 block text-sm font-medium text-gray-700">
出图方式 <span className="text-red-500">*</span>
</label>
<Select
id="task-detail-source-type"
value={taskDetailSourceType}
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
disabled={uploading}
required
aria-required="true"
aria-invalid={Boolean(error && !taskDetailSourceType)}
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
>
<option value="">请选择出图方式</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</Select>
</div>
</div>
{/* 使用 UploadForm 组件显示 SPU、店铺、场景字段 */}
<div className={`${styles.productFields} grid gap-x-4 gap-y-3 sm:grid-cols-2`}>
<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>
<div className="space-y-1">
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<label htmlFor="task-detail-demand-type" className="mb-1 block text-sm font-medium text-gray-700">
需求类型 <span className="text-red-500">*</span>
</label>
<Select
id="task-detail-demand-type"
value={taskDetailDemandType}
onChange={(e) => handleTaskDetailDemandTypeChange(e.target.value as TaskDetailDemandType)}
disabled={uploading}
required
aria-required="true"
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
>
{TASK_DETAIL_DEMAND_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
</div>
<div>
<label htmlFor="task-detail-visibility" className="mb-1 block text-sm font-medium text-gray-700">
可见性
</label>
<Select
id="task-detail-visibility"
value={taskDetailVisibilityValue}
onChange={(e) => handleTaskDetailVisibilityChange(e.target.value as 'public' | 'private')}
disabled={uploading}
aria-describedby="task-detail-visibility-hint"
className={TASK_DETAIL_METADATA_FIELD_CLASS_NAME}
>
<option value="public">公开</option>
<option value="private" disabled={!isOptimizationDemand}>私密</option>
</Select>
</div>
</div>
<p id="task-detail-visibility-hint" className="text-xs leading-5 text-gray-500">
{taskDetailPrivacyHint}
</p>
</div>
</div>
<div className="space-y-3 border-t border-gray-200 pt-3">
<p className="text-xs leading-5 text-gray-500">
同时上传主图 + SKU 图 + 详情图时自动标记为整套
</p>
<div className="grid gap-4 sm:grid-cols-2">
{/* 主图上传 */}
<div className={`min-w-0 ${mainImages.length || skuImages.length ? 'sm:col-span-2' : ''}`}>
<label htmlFor="main-images-input" className="mb-1 block text-sm font-medium text-gray-700">
主图
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择主图文件"
className={`p-2 ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${isMainDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
onDragEnter={(e) => handleDragEnter(e, setIsMainDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'main', setIsMainDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())}
>
<input
id="main-images-input"
ref={mainInputRef}
type="file"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => {
handleImageSelect(e.target.files, 'main');
e.currentTarget.value = '';
}}
className="hidden"
disabled={uploading}
/>
<div className={mainImages.length ? 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5' : ''}>
{mainImages.map((img) => (
<div key={img.id} className="relative group">
<div className="relative w-full h-20 rounded-[8px] overflow-hidden border border-[#e7e8ea]">
<AssetThumbnail asset={img} alt="" />
</div>
<button
onClick={() => handleRemoveFile(img.id, 'main')}
aria-label={`删除主图 ${img.file.name}`}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
disabled={uploading}
type="button"
>
×
</button>
</div>
))}
<button
type="button"
aria-label="添加主图"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className={`group flex w-full items-center justify-center gap-2 rounded-[8px] bg-white text-sm text-[#1a1c1d] transition-colors hover:bg-[#edf0f2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50 ${mainImages.length ? 'h-20 border border-dashed border-[#cbd5e1]' : 'min-h-11'}`}
>
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>添加主图</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{mainImages.length === 0 && <span>添加主图</span>}
</button>
</div>
</div>
</div>
{/* SKU图上传 */}
<div className={`min-w-0 ${mainImages.length || skuImages.length ? 'sm:col-span-2' : ''}`}>
<div className="mb-1 flex min-h-5 flex-wrap items-center justify-between gap-x-3 gap-y-1">
<label htmlFor="sku-images-input" className="block text-sm font-medium text-gray-700">
SKU图
</label>
<label className="flex min-h-11 cursor-pointer select-none items-center gap-2 text-sm leading-5 text-gray-600 sm:min-h-5">
<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
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择 SKU 图文件"
className={`p-2 ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${isSkuDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
onDragEnter={(e) => handleDragEnter(e, setIsSkuDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'sku', setIsSkuDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => skuInputRef.current?.click())}
>
<input
id="sku-images-input"
ref={skuInputRef}
type="file"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => {
handleImageSelect(e.target.files, 'sku');
e.currentTarget.value = '';
}}
className="hidden"
disabled={uploading}
/>
<div className={skuImages.length ? 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5' : ''}>
{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-[8px] overflow-hidden border border-[#e7e8ea]">
<AssetThumbnail asset={img} alt="" />
</div>
<button
onClick={() => handleRemoveFile(img.id, 'sku')}
aria-label={`删除 SKU 图 ${img.file.name}`}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
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"
aria-label="添加SKU图"
onClick={() => skuInputRef.current?.click()}
disabled={uploading}
className={`group flex w-full items-center justify-center gap-2 rounded-[8px] bg-white text-sm text-[#1a1c1d] transition-colors hover:bg-[#edf0f2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50 ${skuImages.length ? 'h-20 border border-dashed border-[#cbd5e1]' : 'min-h-11'}`}
>
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>添加SKU图</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{skuImages.length === 0 && <span>添加SKU图</span>}
</button>
</div>
</div>
</div>
{/* 详情图上传 */}
<div className="min-w-0 sm:col-span-2">
<label htmlFor="detail-images-input" className="mb-1 block text-sm font-medium text-gray-700">
详情图
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择详情图文件"
className={`p-2 ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${isDetailDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
onDragEnter={(e) => handleDragEnter(e, setIsDetailDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsDetailDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'detail', setIsDetailDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => detailInputRef.current?.click())}
>
<input
id="detail-images-input"
ref={detailInputRef}
type="file"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => {
handleImageSelect(e.target.files, 'detail');
e.currentTarget.value = '';
}}
className="hidden"
disabled={uploading}
/>
{/* 上传按钮 */}
<div className={`flex flex-wrap items-center gap-x-3 gap-y-1 ${detailImages.length ? 'mb-3' : ''}`}>
<button
onClick={() => detailInputRef.current?.click()}
className={styles.fileButton}
disabled={uploading}
type="button"
>
{detailImages.length > 0 ? '继续添加' : '选择详情图'}
</button>
<span className="text-xs leading-5 text-gray-500">JPG / PNG / GIF / WebP,支持拖拽</span>
</div>
{/* 详情图排序组件 */}
{detailImages.length > 0 && (
<DetailImageSorter
images={detailImages}
onChange={setDetailImages}
onRemove={(id) => handleRemoveFile(id, 'detail')}
disabled={uploading}
/>
)}
</div>
</div>
{/* PSD上传 */}
<div className="min-w-0 sm:col-span-2">
<label htmlFor="psd-files-input" className="mb-1 block text-sm font-medium text-gray-700">
PSD源文件(可选)
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择 PSD 源文件"
className={`p-2 ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${isPsdDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
onDragEnter={(e) => handleDragEnter(e, setIsPsdDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsPsdDragging)}
onDragOver={handleDragOver}
onDrop={(e) => {
handleDrop(e, 'psd', setIsPsdDragging, 'psd');
}}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => psdInputRef.current?.click())}
>
<input
id="psd-files-input"
ref={psdInputRef}
type="file"
accept={TASK_DETAIL_SOURCE_FILE_ACCEPT}
multiple
onChange={(e) => {
handlePsdSelect(e.target.files);
e.currentTarget.value = '';
}}
className="hidden"
disabled={uploading}
/>
{/* 上传按钮 */}
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<button
onClick={() => psdInputRef.current?.click()}
className={styles.fileButton}
disabled={uploading}
type="button"
>
{psdFiles.length > 0 ? `继续添加 (${psdFiles.length}个)` : '选择源文件'}
</button>
<span className="min-w-0 break-words text-xs leading-5 text-gray-500">{TASK_DETAIL_SOURCE_FILE_LABEL}</span>
{psdFiles.length > 0 && (
<div className="flex w-full min-w-0 flex-wrap gap-1.5">
{psdFiles.map((psdFile) => (
<span key={psdFile.id} className="inline-flex max-w-full items-center gap-1 rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
<span className="min-w-0 break-all">{psdFile.file.name}</span>
<button
onClick={() => handleRemoveFile(psdFile.id, 'psd')}
aria-label={`删除源文件 ${psdFile.file.name}`}
className="flex h-8 w-8 shrink-0 items-center justify-center text-red-500 hover:text-red-600"
disabled={uploading}
type="button"
>
×
</button>
</span>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* 错误提示 */}
{error && (
<div ref={errorRef} role="alert" aria-live="assertive" className={`${styles.feedback} break-words rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600`}>
{error}
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div
role="progressbar"
aria-label="素材上传进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={uploadProgress}
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 items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-gray-700 dark:text-gray-300">{uploadProgress}%</span>
{processingStep && (
<span aria-live="polite" className="min-w-0 break-all text-right text-gray-500 dark:text-gray-400">{processingStep}</span>
)}
</div>
</div>
)}
{/* 操作按钮 */}
<div className="sticky bottom-0 -mx-1 border-t border-[#e7e8ea] bg-white px-1 py-2">
<div className="flex justify-end space-x-3">
<button
onClick={uploading ? handleCancelUpload : onCancel}
className={outlineButtonClass}
type="button"
>
{uploading ? '取消上传' : '取消'}
</button>
<button
onClick={handleSubmit}
className={inkButtonClass}
disabled={uploading || usersLoading || Boolean(usersLoadError)}
type="button"
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
</div>
);
}