任务详情上传放开图片上限并按命名规则自动预填SKU编号与条码
This commit is contained in:
parent
586d4cd147
commit
ec782a914e
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
39
lib/__tests__/sku-barcode-detector.test.ts
Normal file
39
lib/__tests__/sku-barcode-detector.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
57
lib/sku-barcode-detector.ts
Normal file
57
lib/sku-barcode-detector.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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。 */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user