diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx index 891ad58..3ed17bc 100644 --- a/components/upload/ProductPackageUploader.test.tsx +++ b/components/upload/ProductPackageUploader.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import ProductPackageUploader, { selectTaskDetailFilesWithinLimits, + stripTrailingImageExtensions, } from '@/components/upload/ProductPackageUploader' import { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error' import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' @@ -947,3 +948,22 @@ describe('ProductPackageUploader', () => { } }) }) + +describe('stripTrailingImageExtensions(SKU 条码预填的文件名剥离)', () => { + it.each([ + ['6901234567890.jpg', '6901234567890'], + ['6901234567890.png', '6901234567890'], + ['6901234567890.jpg.jpg', '6901234567890'], + ['6901234567890.png.png', '6901234567890'], + ['6901234567890.jpg.png', '6901234567890'], + ['6901234567890.png.jpg', '6901234567890'], + ['6901234567890.JPG.PNG', '6901234567890'], + ['6901234567890.jpeg.webp', '6901234567890'], + ['6901234567890.jpg.png.webp.tiff', '6901234567890'], + ['6901234567890', '6901234567890'], + ['690.123.456.jpg', '690.123.456'], + ['条码- A001.png', '条码- A001'], + ])('%s → %s', (input, expected) => { + expect(stripTrailingImageExtensions(input)).toBe(expected) + }) +}) diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index 5cfbac8..6878eab 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -21,6 +21,7 @@ import { } 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, @@ -275,6 +276,20 @@ function parseBarcodeInput(input: string): string[] { ); } +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; +} + function AssetThumbnail({ asset, alt, @@ -622,15 +637,28 @@ export default function ProductPackageUploader({ const appendSelectedFiles = (group: UploadGroup, files: File[]) => { if (files.length === 0) return; - const preparedFiles: AssetFile[] = files.map((file) => ({ - file, - // 原始 File 不进入 img;图片缩略图由单通道 Worker 异步生成。 - preview: '', - previewStatus: group === 'psd' ? 'unavailable' : 'loading', - previewMessage: group === 'psd' ? '源文件无需预览' : '正在生成缩略图', - id: Math.random().toString(36).substring(2, 11), - sortOrder: 0, - })); + 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], diff --git a/lib/__tests__/sku-barcode-detector.test.ts b/lib/__tests__/sku-barcode-detector.test.ts new file mode 100644 index 0000000..8a827ac --- /dev/null +++ b/lib/__tests__/sku-barcode-detector.test.ts @@ -0,0 +1,39 @@ +import { detectSkuOrBarcodeName } from '@/lib/sku-barcode-detector' + +describe('detectSkuOrBarcodeName(SKU 图文件名判别)', () => { + it.each([ + // 条码:SKU 编码 + N数字V数字 段(生产数据真实形态) + ['DNZ038521N1280V01', 'barcode'], + ['CY001306N35V01', 'barcode'], + ['BJ017603N1241V01', 'barcode'], + ['cy001301N35', 'barcode'], // 小写品类码也能识别 + ['GBSFY-DNZ038415N1261V01', 'barcode'], // 带店铺前缀 + ])('%s → %s', (input, expected) => { + expect(detectSkuOrBarcodeName(input)?.type).toBe(expected) + // value 保留原始大小写 + expect(detectSkuOrBarcodeName(input)?.value).toBe(input) + }) + + it.each([ + // SKU 编号:品类码 + 纯数字 + ['DNZ038521', 'skuCode'], + ['AD033601', 'skuCode'], + ['GBSFY-AD0360', 'skuCode'], // 带店铺前缀 + ])('%s → %s', (input, expected) => { + expect(detectSkuOrBarcodeName(input)?.type).toBe(expected) + }) + + it.each([ + // 命名不规范:一律不自动填 + ['1'], + ['10'], + ['02-1'], + ['MKH-DNZ038528N1280V01-1-GBGJ-DNZ038415N1261V01-5'], // 复合串 + ['GBPM-BJ019201N1280V01场景'], // 含中文 + ['GBSFYAD0360'], // 店铺前缀无连字符,无法区分前缀与品类码,从严不填 + ['随便起的名字'], + [''], + ])('%s → 不填(null)', (input) => { + expect(detectSkuOrBarcodeName(input)).toBeNull() + }) +}) diff --git a/lib/sku-barcode-detector.ts b/lib/sku-barcode-detector.ts new file mode 100644 index 0000000..b4562db --- /dev/null +++ b/lib/sku-barcode-detector.ts @@ -0,0 +1,57 @@ +/** + * SKU 图文件名 → SKU 编号 / 产品条码 自动判别 + * + * 依据生产数据的稳定结构(配合 lib/spu-parser.ts 的品类字典): + * - SKU 编号: 品类码 + 纯数字,如 DNZ038521、AD033601,可带店铺前缀 GBSFY-AD0360 + * - 产品条码: SKU 编号 + N数字V数字 段,如 DNZ038521N1280V01、CY001306N35V01 + * - 其余(纯序号、02-1、复合串、含中文等)判别不出,交给人工填写 + * + * 判定从严:两条规则都是整串锚定匹配,同时命中或都不命中一律返回 null,绝不猜。 + */ +import { getCategoryCodes, getShopPrefixes } from '@/lib/spu-parser' + +export type SkuOrBarcodeDetection = + | { type: 'barcode'; value: string } + | { type: 'skuCode'; value: string } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function buildPattern(kind: 'barcode' | 'skuCode'): RegExp { + // 长前缀/长品类码放在 alternation 前面,避免短码抢先匹配(如 AD 抢掉 ADX 前缀的场景)。 + const shops = getShopPrefixes() + .map(escapeRegExp) + .sort((a, b) => b.length - a.length) + .join('|') + const categories = getCategoryCodes() + .map(escapeRegExp) + .sort((a, b) => b.length - a.length) + .join('|') + + const shopPart = shops ? `(?:(?:${shops})-)?` : '' + // N 段必有,V 段可省(生产数据存在 cy001301N35 这种无 V 段的真实条码)。 + const tail = kind === 'barcode' ? 'N\\d+(?:V\\d+)?' : '' + return new RegExp(`^${shopPart}(?:${categories})\\d+${tail}$`, 'i') +} + +const BARCODE_PATTERN = buildPattern('barcode') +const SKU_CODE_PATTERN = buildPattern('skuCode') + +/** + * 判别一个 SKU 图文件名(应先剥掉图片后缀)。 + * 返回 null 表示命名不规范,不自动填,由上传人员人工填写。 + */ +export function detectSkuOrBarcodeName(name: string): SkuOrBarcodeDetection | null { + const trimmed = String(name || '').trim() + if (!trimmed) return null + + // 整串锚定:两条规则天然互斥(skuCode 规则的 \d+$ 不容 N/V 段),顺序无关。 + if (BARCODE_PATTERN.test(trimmed)) { + return { type: 'barcode', value: trimmed } + } + if (SKU_CODE_PATTERN.test(trimmed)) { + return { type: 'skuCode', value: trimmed } + } + return null +} diff --git a/lib/task-detail-upload-policy.ts b/lib/task-detail-upload-policy.ts index b2f8d6b..4dcb2d8 100644 --- a/lib/task-detail-upload-policy.ts +++ b/lib/task-detail-upload-policy.ts @@ -3,13 +3,13 @@ import { MAX_LOGICAL_UPLOAD_BYTES } from '@/lib/material-upload-chunk-policy' export const TASK_DETAIL_UPLOAD_LIMITS = { /** 单个图片原图的保护上限,避免浏览器解码/服务器兜底处理一次吞掉过多内存。 */ maxSingleImageBytes: 100 * 1024 * 1024, - /** 一次任务允许的图片数量上限;详情长图的切片不计入此门禁。 */ - maxImagesPerGroup: 60, - maxTotalImageCount: 120, + /** 一次任务允许的图片数量上限;详情长图的切片不计入此门禁。生产存量素材 SKU 图最多 220 张,上限需覆盖真实业务。 */ + maxImagesPerGroup: 300, + maxTotalImageCount: 600, /** PSD/PSB/TIFF 等源文件不是图片计数的一部分,单独限制,防止小文件堆积绕过图片上限。 */ maxPsdFileCount: 20, /** 原图与源文件的总条目数上限。 */ - maxTotalSourceFileCount: 140, + maxTotalSourceFileCount: 700, /** 仅图片原图总大小。PSD/压缩包不算进这项,避免把源文件误卡在 1GB。 */ maxTotalSourceBytes: 1024 * 1024 * 1024, /** 单个 PSD/压缩包与分片上传单文件上限对齐。 */ @@ -17,7 +17,7 @@ export const TASK_DETAIL_UPLOAD_LIMITS = { /** 一次提交的源文件总量:大约够主图/SKU/详情各一份大源文件。 */ maxTotalPsdBytes: 3 * MAX_LOGICAL_UPLOAD_BYTES, maxPreviewCountPerImage: 60, - maxTotalPreviewCount: 1_000, + maxTotalPreviewCount: 2_000, maxSinglePreviewBytes: 10 * 1024 * 1024, maxTotalPreviewBytes: 512 * 1024 * 1024, /** 普通预览为 800x1200,详情切片最大 1600x1500。 */