任务详情上传放开图片上限并按命名规则自动预填SKU编号与条码

This commit is contained in:
chenpei 2026-08-25 19:28:58 +08:00
parent 586d4cd147
commit ec782a914e
5 changed files with 158 additions and 14 deletions

View File

@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import ProductPackageUploader, { import ProductPackageUploader, {
selectTaskDetailFilesWithinLimits, selectTaskDetailFilesWithinLimits,
stripTrailingImageExtensions,
} from '@/components/upload/ProductPackageUploader' } from '@/components/upload/ProductPackageUploader'
import { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error' import { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' 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)
})
})

View File

@ -21,6 +21,7 @@ import {
} from './drag-drop-utils'; } from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields'; import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { detectSkuOrBarcodeName } from '@/lib/sku-barcode-detector';
import { import {
TASK_DETAIL_UPLOAD_LIMITS, TASK_DETAIL_UPLOAD_LIMITS,
validateTaskDetailUploadFiles, 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({ function AssetThumbnail({
asset, asset,
alt, alt,
@ -622,15 +637,28 @@ export default function ProductPackageUploader({
const appendSelectedFiles = (group: UploadGroup, files: File[]) => { const appendSelectedFiles = (group: UploadGroup, files: File[]) => {
if (files.length === 0) return; if (files.length === 0) return;
const preparedFiles: AssetFile[] = files.map((file) => ({ const preparedFiles: AssetFile[] = files.map((file) => {
file, // SKU 图按部门命名习惯自动预填:品类码+纯数字 → SKU 编号框,带 N..V.. 段 → 条码框;
// 原始 File 不进入 img;图片缩略图由单通道 Worker 异步生成。 // 判别不出(序号、复合串等)不自动填,由上传人员人工填写。
preview: '', let autoSkuCode: string | undefined;
previewStatus: group === 'psd' ? 'unavailable' : 'loading', let autoBarcodeInput: string | undefined;
previewMessage: group === 'psd' ? '源文件无需预览' : '正在生成缩略图', if (group === 'sku') {
id: Math.random().toString(36).substring(2, 11), const detection = detectSkuOrBarcodeName(stripTrailingImageExtensions(file.name));
sortOrder: 0, 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 = {
...selectionSnapshotRef.current, ...selectionSnapshotRef.current,
[group]: [...selectionSnapshotRef.current[group], ...files], [group]: [...selectionSnapshotRef.current[group], ...files],

View File

@ -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()
})
})

View File

@ -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
}

View File

@ -3,13 +3,13 @@ import { MAX_LOGICAL_UPLOAD_BYTES } from '@/lib/material-upload-chunk-policy'
export const TASK_DETAIL_UPLOAD_LIMITS = { export const TASK_DETAIL_UPLOAD_LIMITS = {
/** 单个图片原图的保护上限,避免浏览器解码/服务器兜底处理一次吞掉过多内存。 */ /** 单个图片原图的保护上限,避免浏览器解码/服务器兜底处理一次吞掉过多内存。 */
maxSingleImageBytes: 100 * 1024 * 1024, maxSingleImageBytes: 100 * 1024 * 1024,
/** 一次任务允许的图片数量上限;详情长图的切片不计入此门禁。 */ /** 一次任务允许的图片数量上限;详情长图的切片不计入此门禁。生产存量素材 SKU 图最多 220 张,上限需覆盖真实业务。 */
maxImagesPerGroup: 60, maxImagesPerGroup: 300,
maxTotalImageCount: 120, maxTotalImageCount: 600,
/** PSD/PSB/TIFF 等源文件不是图片计数的一部分,单独限制,防止小文件堆积绕过图片上限。 */ /** PSD/PSB/TIFF 等源文件不是图片计数的一部分,单独限制,防止小文件堆积绕过图片上限。 */
maxPsdFileCount: 20, maxPsdFileCount: 20,
/** 原图与源文件的总条目数上限。 */ /** 原图与源文件的总条目数上限。 */
maxTotalSourceFileCount: 140, maxTotalSourceFileCount: 700,
/** 仅图片原图总大小。PSD/压缩包不算进这项,避免把源文件误卡在 1GB。 */ /** 仅图片原图总大小。PSD/压缩包不算进这项,避免把源文件误卡在 1GB。 */
maxTotalSourceBytes: 1024 * 1024 * 1024, maxTotalSourceBytes: 1024 * 1024 * 1024,
/** 单个 PSD/压缩包与分片上传单文件上限对齐。 */ /** 单个 PSD/压缩包与分片上传单文件上限对齐。 */
@ -17,7 +17,7 @@ export const TASK_DETAIL_UPLOAD_LIMITS = {
/** 一次提交的源文件总量:大约够主图/SKU/详情各一份大源文件。 */ /** 一次提交的源文件总量:大约够主图/SKU/详情各一份大源文件。 */
maxTotalPsdBytes: 3 * MAX_LOGICAL_UPLOAD_BYTES, maxTotalPsdBytes: 3 * MAX_LOGICAL_UPLOAD_BYTES,
maxPreviewCountPerImage: 60, maxPreviewCountPerImage: 60,
maxTotalPreviewCount: 1_000, maxTotalPreviewCount: 2_000,
maxSinglePreviewBytes: 10 * 1024 * 1024, maxSinglePreviewBytes: 10 * 1024 * 1024,
maxTotalPreviewBytes: 512 * 1024 * 1024, maxTotalPreviewBytes: 512 * 1024 * 1024,
/** 普通预览为 800x1200,详情切片最大 1600x1500。 */ /** 普通预览为 800x1200,详情切片最大 1600x1500。 */