+
{item.file.name}
@@ -125,15 +126,32 @@ export function BatchVideoList({
{formatBytes(item.file.size)}
-
onUpdateProductModel(index, e.target.value)}
- disabled={disabled}
- className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white disabled:cursor-not-allowed disabled:opacity-60"
- placeholder="SPU型号"
- />
+
+
onUpdateProductModel(index, e.target.value)}
+ disabled={disabled}
+ className={`w-full rounded-apple border px-3 py-1.5 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:bg-gray-700 dark:text-white ${
+ !isPlausibleSpuProductModel(item.productModel)
+ ? 'border-red-400 focus:border-red-500 focus:ring-2 focus:ring-red-100 dark:border-red-600 dark:focus:ring-red-900'
+ : 'border-gray-300 focus:border-gray-900 focus:ring-2 focus:ring-gray-200 dark:border-gray-600 dark:focus:border-gray-400 dark:focus:ring-gray-600'
+ }`}
+ placeholder="SPU型号"
+ />
+ {!isPlausibleSpuProductModel(item.productModel) && (
+
+ {getSpuProductModelValidationError(item.productModel)}
+
+ )}
+
{isVideoMaterialType(materialType) && (
-
- 请填写真实 SPU;纯数字或文件尾号不能作为 SPU。
+
+ {productModel
+ ? getSpuProductModelValidationError(productModel) || '格式正确;英文字母已自动转成大写。'
+ : '请填写真实 SPU;英文字母会自动转成大写,纯数字或文件尾号不能作为 SPU。'}
)}
diff --git a/components/upload/file-handlers.product-model.test.ts b/components/upload/file-handlers.product-model.test.ts
index ebb1b5b..a78d5e5 100644
--- a/components/upload/file-handlers.product-model.test.ts
+++ b/components/upload/file-handlers.product-model.test.ts
@@ -22,7 +22,7 @@ describe('批量视频 SPU 自动识别', () => {
test('只把文件名中可确认的型号作为 SPU', () => {
const items = createBatchFileItems(
[
- new File(['video'], 'GBPM-SN0204-展示.mp4', { type: 'video/mp4' }),
+ new File(['video'], 'GBPM-SN0204K12.mp4', { type: 'video/mp4' }),
new File(['video'], 'DF-A3DZ0008.mp4', { type: 'video/mp4' }),
],
'抖音-千川素材',
@@ -37,8 +37,24 @@ describe('批量视频 SPU 自动识别', () => {
productModel: item.productModel,
productModelSource: item.productModelSource,
}))).toEqual([
- { productModel: 'GBPM-SN0204', productModelSource: 'filename' },
+ { productModel: 'GBPM-SN0204K12', productModelSource: 'filename' },
{ productModel: 'DF-A3DZ0008', productModelSource: 'filename' },
])
})
+
+ test('带说明文字的文件名不截取其中一段冒充 SPU', () => {
+ const [item] = createBatchFileItems(
+ [new File(['video'], '产品-GBPM-SN0204-展示.mp4', { type: 'video/mp4' })],
+ '抖音-千川素材',
+ '',
+ '',
+ '',
+ '',
+ '',
+ )
+
+ expect(item.productModel).toBe('')
+ expect(item.productModelSource).toBeUndefined()
+ expect(item.expanded).toBe(true)
+ })
})
diff --git a/components/upload/upload-utils.test.ts b/components/upload/upload-utils.test.ts
index cf7b49f..db5ca58 100644
--- a/components/upload/upload-utils.test.ts
+++ b/components/upload/upload-utils.test.ts
@@ -25,7 +25,7 @@ describe('upload-utils video types', () => {
['抖音素材-624-456.mp4', ''],
['视频-624-470.mp4', ''],
['624-456.mp4', ''],
- ['GBPM-SN0204-展示.mp4', 'GBPM-SN0204'],
+ ['GBPM-SN0204-展示.mp4', ''],
['DF-A3DZ0008.mp4', 'DF-A3DZ0008'],
['626DNZ0385-1.mp4', '626DNZ0385-1'],
['XY-2024-001.mp4', 'XY-2024-001'],
diff --git a/lib/__tests__/product-model.test.ts b/lib/__tests__/product-model.test.ts
index bb4c576..93b188f 100644
--- a/lib/__tests__/product-model.test.ts
+++ b/lib/__tests__/product-model.test.ts
@@ -1,8 +1,8 @@
import {
extractPlausibleSpuFromFileName,
+ getSpuProductModelValidationError,
isPlausibleSpuProductModel,
normalizeSpuProductModel,
- sanitizeSpuProductModelInput,
} from '@/lib/product-model'
describe('product-model', () => {
@@ -16,13 +16,15 @@ describe('product-model', () => {
(value) => expect(isPlausibleSpuProductModel(value)).toBe(false),
)
- test('统一去空格并转成大写', () => {
- expect(normalizeSpuProductModel(' df-a3dz0008 ')).toBe('DF-A3DZ0008')
+ test('只自动转成大写,不删除用户输入的任何字符', () => {
+ expect(normalizeSpuProductModel(' df-a3dz0008 中文 ')).toBe(' DF-A3DZ0008 中文 ')
+ expect(normalizeSpuProductModel('ß1-ſ2-ı3-ff4')).toBe('ß1-ſ2-ı3-ff4')
+ expect(isPlausibleSpuProductModel('ß1')).toBe(false)
})
- test('输入清理保留组合 SPU 的合法加号', () => {
- expect(sanitizeSpuProductModelInput(' gbgj-dnz0366+gbgj-sj0251 '))
- .toBe('GBGJ-DNZ0366+GBGJ-SJ0251')
+ test('不合规内容原样保留后由校验给出明确原因', () => {
+ expect(getSpuProductModelValidationError(' DF-A3DZ0008')).toBe('SPU 前后不能包含空格')
+ expect(getSpuProductModelValidationError('DF-A3DZ0008中文')).toContain('只能使用')
})
test.each([
@@ -30,7 +32,7 @@ describe('product-model', () => {
['GBGJ-DNZ0366+GBGJ-SJ0251.mp4', 'GBGJ-DNZ0366+GBGJ-SJ0251'],
['GBPM-SN0204K12.mp4', 'GBPM-SN0204K12'],
['626DNZ0385-1.mp4', '626DNZ0385-1'],
- ['产品-GBPM-SN0204-展示.mp4', 'GBPM-SN0204'],
+ ['产品-GBPM-SN0204-展示.mp4', ''],
])('从文件名 %s 安全识别完整 SPU', (fileName, expected) => {
expect(extractPlausibleSpuFromFileName(fileName)).toBe(expected)
})
diff --git a/lib/product-model.ts b/lib/product-model.ts
index 989e1cc..e442583 100644
--- a/lib/product-model.ts
+++ b/lib/product-model.ts
@@ -1,18 +1,32 @@
const PRODUCT_MODEL_ALLOWED_CHARACTERS = /^[A-Z0-9][A-Z0-9+_.-]*[A-Z0-9]$/
/**
- * SPU 在接口和页面中统一使用大写,避免同一型号因大小写形成两组素材。
+ * SPU 唯一允许的自动格式化是转成大写。空格、中文和其它非法字符必须
+ * 原样留在输入框里交给校验提示,不能静默删除后把另一个型号写入数据库。
*/
export function normalizeSpuProductModel(value: unknown): string {
- return typeof value === 'string' ? value.trim().toUpperCase() : ''
+ return typeof value === 'string'
+ ? value.replace(/[a-z]/g, character => character.toUpperCase())
+ : ''
}
-/**
- * 输入框只保留与后端校验一致的字符,避免合法的组合 SPU(例如 A+B)
- * 在浏览器里被静默改写。
- */
-export function sanitizeSpuProductModelInput(value: unknown): string {
- return normalizeSpuProductModel(value).replace(/[^A-Z0-9+_.-]/g, '')
+export const SPU_PRODUCT_MODEL_FORMAT_HINT =
+ '必须同时包含英文字母和数字,只能使用字母、数字及 +、-、_、.,且不能以符号开头或结尾'
+
+export function getSpuProductModelValidationError(value: unknown): string | null {
+ const normalized = normalizeSpuProductModel(value)
+
+ if (!normalized) return '请填写真实 SPU,例如 DF-A3DZ0008'
+ if (normalized.length > 100) return 'SPU 不能超过 100 个字符'
+ if (normalized !== normalized.trim()) return 'SPU 前后不能包含空格'
+ if (!/[A-Z]/.test(normalized) || !/\d/.test(normalized)) {
+ return 'SPU 必须同时包含英文字母和数字'
+ }
+ if (!PRODUCT_MODEL_ALLOWED_CHARACTERS.test(normalized)) {
+ return `SPU ${SPU_PRODUCT_MODEL_FORMAT_HINT}`
+ }
+
+ return null
}
/**
@@ -21,13 +35,7 @@ export function sanitizeSpuProductModelInput(value: unknown): string {
* 共同边界是包含英文字母和数字,且不能以分隔符开头或结尾。
*/
export function isPlausibleSpuProductModel(value: unknown): boolean {
- const normalized = normalizeSpuProductModel(value)
-
- return normalized.length >= 2
- && normalized.length <= 100
- && /[A-Z]/.test(normalized)
- && /\d/.test(normalized)
- && PRODUCT_MODEL_ALLOWED_CHARACTERS.test(normalized)
+ return getSpuProductModelValidationError(value) === null
}
/**
@@ -37,16 +45,7 @@ export function isPlausibleSpuProductModel(value: unknown): boolean {
export function extractPlausibleSpuFromFileName(filename: string): string {
const nameWithoutExtension = normalizeSpuProductModel(filename.replace(/\.[^/.]+$/, ''))
- // 文件名本身就是合法 SPU 时必须完整保留,不能截掉 +组合、K12 后缀或数字前缀。
- if (isPlausibleSpuProductModel(nameWithoutExtension)) {
- return nameWithoutExtension
- }
-
- // 中文说明包裹 SPU 时,按不受支持的字符切段,再移除段首尾分隔符。
- const candidates = nameWithoutExtension
- .split(/[^A-Z0-9+_.-]+/)
- .map(candidate => candidate.replace(/^[+_.-]+|[+_.-]+$/g, ''))
- .filter(Boolean)
-
- return candidates.find(isPlausibleSpuProductModel) || ''
+ // 只接受整个文件名(不含扩展名)就是合法 SPU;带说明文字或额外字符时留空,
+ // 由上传人确认填写,绝不从文件名中截取一段并冒充用户的 SPU。
+ return isPlausibleSpuProductModel(nameWithoutExtension) ? nameWithoutExtension : ''
}