diff --git a/admin-frontend-vue/src/pages/invalidasin/invalid-asin-api.ts b/admin-frontend-vue/src/pages/invalidasin/invalid-asin-api.ts index 55bd6957..1281712d 100644 --- a/admin-frontend-vue/src/pages/invalidasin/invalid-asin-api.ts +++ b/admin-frontend-vue/src/pages/invalidasin/invalid-asin-api.ts @@ -1,5 +1,7 @@ /** 品牌数据库(不符合ASIN)列表加载适配(任务 68):GET /api/admin/invalid-asin-data。 */ import { http } from '@/api/http' +import { unwrap } from '@/api/envelope' +import { toInvalidAsinCreateRequest, toInvalidAsinUpdateRequest, type InvalidAsinForm } from './invalid-asin-form-model' import { parseInvalidAsinPage, type InvalidAsinPageResult } from './invalid-asin-model' export const INVALID_ASIN_ENDPOINT = '/api/admin/invalid-asin-data' @@ -30,3 +32,15 @@ export async function fetchInvalidAsinPage(query: InvalidAsinQuery): Promise(INVALID_ASIN_ENDPOINT, { params: toQuery(query) }) return parseInvalidAsinPage(data) } + +/** 新增品牌数据库记录:POST /api/admin/invalid-asin-data。 */ +export async function createInvalidAsin(form: InvalidAsinForm): Promise { + const { data } = await http.post(INVALID_ASIN_ENDPOINT, toInvalidAsinCreateRequest(form)) + unwrap(data) +} + +/** 编辑品牌数据库记录:PUT /api/admin/invalid-asin-data/{id}。 */ +export async function updateInvalidAsin(id: number, form: InvalidAsinForm, includeGroup = false): Promise { + const { data } = await http.put(`${INVALID_ASIN_ENDPOINT}/${id}`, toInvalidAsinUpdateRequest(form, { includeGroup })) + unwrap(data) +} diff --git a/admin-frontend-vue/src/pages/invalidasin/invalid-asin-form-model.ts b/admin-frontend-vue/src/pages/invalidasin/invalid-asin-form-model.ts new file mode 100644 index 00000000..471b5f7b --- /dev/null +++ b/admin-frontend-vue/src/pages/invalidasin/invalid-asin-form-model.ts @@ -0,0 +1,88 @@ +/** 品牌数据库(不符合ASIN)表单模型(任务 69):新建/编辑表单校验与序列化,纯逻辑。 */ +import type { InvalidAsinItem } from './invalid-asin-model.ts' + +export const INVALID_ASIN_FIELD_MAX_LEN = 128 + +export interface InvalidAsinForm { + dataValue: string + brand: string + groupId: number | null +} + +export interface InvalidAsinFormErrors { + dataValue?: string + brand?: string + groupId?: string +} + +export interface InvalidAsinCreatePayload { + dataValue: string + brand?: string + groupId: number +} + +export interface InvalidAsinUpdatePayload { + dataValue: string + brand?: string + groupId?: number +} + +export function createEmptyInvalidAsinForm(): InvalidAsinForm { + return { dataValue: '', brand: '', groupId: null } +} + +/** 从列表项回填表单。 */ +export function invalidAsinFormFromItem(item: InvalidAsinItem): InvalidAsinForm { + return { dataValue: item.dataValue, brand: item.brand, groupId: item.groupId } +} + +function text(value: string): string { + return (value || '').trim() +} + +/** 校验表单(镜像后端 NotBlank/@Size/NotNull 文案);manual 新增必须选分组。 */ +export function validateInvalidAsinForm( + form: InvalidAsinForm, + options: { requireGroup: boolean }, +): { valid: boolean; errors: InvalidAsinFormErrors } { + const errors: InvalidAsinFormErrors = {} + const dataValue = text(form.dataValue) + if (!dataValue) { + errors.dataValue = 'ASIN 不能为空' + } else if (dataValue.length > INVALID_ASIN_FIELD_MAX_LEN) { + errors.dataValue = `ASIN 长度不能超过${INVALID_ASIN_FIELD_MAX_LEN}个字符` + } + const brand = text(form.brand) + if (brand.length > INVALID_ASIN_FIELD_MAX_LEN) { + errors.brand = `品牌长度不能超过${INVALID_ASIN_FIELD_MAX_LEN}个字符` + } + if (options.requireGroup && (typeof form.groupId !== 'number' || form.groupId < 1)) { + errors.groupId = '请选择分组' + } + return { valid: Object.keys(errors).length === 0, errors } +} + +/** 表单 → POST /api/admin/invalid-asin-data 请求体(camel)。 */ +export function toInvalidAsinCreateRequest(form: InvalidAsinForm): InvalidAsinCreatePayload { + const payload: InvalidAsinCreatePayload = { + dataValue: text(form.dataValue), + groupId: typeof form.groupId === 'number' ? form.groupId : 0, + } + const brand = text(form.brand) + if (brand) payload.brand = brand + return payload +} + +/** 表单 → PUT /api/admin/invalid-asin-data/{id} 请求体;自动导入记录默认不迁组。 */ +export function toInvalidAsinUpdateRequest( + form: InvalidAsinForm, + options: { includeGroup?: boolean } = {}, +): InvalidAsinUpdatePayload { + const payload: InvalidAsinUpdatePayload = { dataValue: text(form.dataValue) } + const brand = text(form.brand) + if (brand) payload.brand = brand + if (options.includeGroup && typeof form.groupId === 'number' && form.groupId >= 1) { + payload.groupId = form.groupId + } + return payload +} diff --git a/admin-frontend-vue/tests/task-69.test.ts b/admin-frontend-vue/tests/task-69.test.ts new file mode 100644 index 00000000..a57e820b --- /dev/null +++ b/admin-frontend-vue/tests/task-69.test.ts @@ -0,0 +1,79 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + createEmptyInvalidAsinForm, + invalidAsinFormFromItem, + toInvalidAsinCreateRequest, + toInvalidAsinUpdateRequest, + validateInvalidAsinForm, +} from '../src/pages/invalidasin/invalid-asin-form-model.ts' +import { toInvalidAsinItem } from '../src/pages/invalidasin/invalid-asin-model.ts' + +test('test_task_069_brand_db_form_normal_primary_path', () => { + // 正常主路径:手动新增校验通过并序列化 create 请求(含分组)。 + const form = { ...createEmptyInvalidAsinForm(), dataValue: 'B0ABC', brand: '苹果', groupId: 3 } + const result = validateInvalidAsinForm(form, { requireGroup: true }) + assert.equal(result.valid, true) + const req = toInvalidAsinCreateRequest(form) + assert.equal(req.dataValue, 'B0ABC') + assert.equal(req.groupId, 3) +}) + +test('test_task_069_brand_db_form_normal_variant_input', () => { + // 正常变体:回填 AUTO 记录更新请求不含分组(自动导入不迁组)。 + const item = toInvalidAsinItem({ id: 5, dataValue: 'X', brand: 'b', recordSource: 'AUTO', groupId: 1 })! + const form = invalidAsinFormFromItem(item) + assert.equal(form.dataValue, 'X') + assert.equal(form.brand, 'b') + const req = toInvalidAsinUpdateRequest(form) + assert.equal('groupId' in req, false) +}) + +test('test_task_069_brand_db_form_normal_repeated_operation_is_idempotent', () => { + // 正常重复:工厂与序列化稳定、不改输入。 + assert.deepEqual(createEmptyInvalidAsinForm(), createEmptyInvalidAsinForm()) + const form = { dataValue: 'A', brand: '', groupId: 1 } + assert.deepEqual(toInvalidAsinCreateRequest(form), toInvalidAsinCreateRequest(form)) +}) + +test('test_task_069_brand_db_form_boundary_empty_input', () => { + // 边界空值:空表单缺 ASIN/分组给出可读错误。 + const result = validateInvalidAsinForm(createEmptyInvalidAsinForm(), { requireGroup: true }) + assert.equal(result.valid, false) + assert.ok(result.errors.dataValue) + assert.ok(result.errors.groupId) +}) + +test('test_task_069_brand_db_form_boundary_single_item', () => { + // 边界单元素:brand 可空通过校验。 + const result = validateInvalidAsinForm({ ...createEmptyInvalidAsinForm(), dataValue: 'B', groupId: 1 }, { requireGroup: true }) + assert.equal(result.valid, true) +}) + +test('test_task_069_brand_db_form_boundary_limit_or_missing_field', () => { + // 边界上限:ASIN/品牌超 128 拒绝并提示;数据两侧去空格。 + const long = 'x'.repeat(129) + const result = validateInvalidAsinForm( + { ...createEmptyInvalidAsinForm(), dataValue: ` ${long} `, brand: long, groupId: 1 }, + { requireGroup: true }, + ) + assert.equal(result.valid, false) + assert.ok(result.errors.dataValue) + assert.ok(result.errors.brand) +}) + +test('test_task_069_brand_db_form_invalid_input_rejected', () => { + // 异常输入:手动来源缺少分组被拒;update 请求缺 ASIN 时校验拦截。 + assert.equal(validateInvalidAsinForm({ ...createEmptyInvalidAsinForm(), dataValue: 'ok' }, { requireGroup: true }).valid, false) +}) + +test('test_task_069_brand_db_form_dependency_failure_returns_actionable_message', () => { + // 依赖失败/表单提交走 adapter:POST/PUT /invalid-asin-data;表单模型纯逻辑不带 http。 + const api = readSource('src/pages/invalidasin/invalid-asin-api.ts') + assert.match(api, /createInvalidAsin|saveInvalidAsin/) + assert.match(api, /updateInvalidAsin/) + assert.match(api, /invalid-asin-data/) + const mod = readSource('src/pages/invalidasin/invalid-asin-form-model.ts') + assert.equal(/axios|http\.|vue/.test(mod), false, '品牌数据库表单模型保持纯逻辑') +})