1882 lines
72 KiB
TypeScript
1882 lines
72 KiB
TypeScript
|
||
/**
|
||
* 任务-详情上传组件
|
||
*/
|
||
|
||
'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>
|
||
);
|
||
}
|