task-79(ASIN 数据中心): 实现商品类目新增/编辑/导出

POST /api/admin/product-category + PUT /product-category/{id} 保存适配
(名称必填/排序可选非负整数校验), 导出 URL(keyword), 8 个契约测试。
This commit is contained in:
2026-09-05 16:17:15 +08:00
parent a46c35f01d
commit 3828c90b4c
4 changed files with 159 additions and 0 deletions
@@ -0,0 +1,15 @@
/** 商品类目新增/编辑适配(任务 79):POST /api/admin/product-category + PUT /api/admin/product-category/{id}。 */
import { http } from '@/api/http'
import type { ProductCategorySaveRequest } from './product-category-editor-model'
export const PRODUCT_CATEGORY_ITEM_ENDPOINT = '/api/admin/product-category'
/** 新增类目。 */
export async function createProductCategory(body: ProductCategorySaveRequest): Promise<void> {
await http.post<unknown>(PRODUCT_CATEGORY_ITEM_ENDPOINT, body)
}
/** 更新类目。 */
export async function updateProductCategory(id: number, body: ProductCategorySaveRequest): Promise<void> {
await http.put<unknown>(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`, body)
}
@@ -0,0 +1,54 @@
/** 商品类目新增/编辑表单模型(任务 79):保存草稿校验与序列化(名称必填、排序可选非负整数),纯逻辑。 */
export interface ProductCategoryDraft {
parentId: number | null
name: string
sortOrder: string
description: string
}
export function createProductCategoryDraft(): ProductCategoryDraft {
return { parentId: null, name: '', sortOrder: '', description: '' }
}
export interface ProductCategorySaveRequest {
parentId?: number
name: string
sortOrder?: number
description?: string
}
function parseSortOrder(text: string): number | null {
const trimmed = (text || '').trim()
if (!trimmed) return null
if (!/^\d+$/.test(trimmed)) return null
const num = Number(trimmed)
return Number.isInteger(num) ? num : null
}
/** 校验保存草稿;合法返回 null,否则返回可读错误。 */
export function categoryDraftError(draft: ProductCategoryDraft): string | null {
if (!draft || typeof draft !== 'object') return '类目表单数据无效'
const name = typeof draft.name === 'string' ? draft.name.trim() : ''
if (!name) return '类目名称不能为空'
const rawSort = typeof draft.sortOrder === 'string' ? draft.sortOrder : ''
if (rawSort.trim() !== '' && parseSortOrder(rawSort) === null) {
return '排序需为非负整数'
}
return null
}
/** 草稿 → 保存请求;仅提交非空/合法字段,名称 trim。 */
export function toCategorySaveRequest(draft: ProductCategoryDraft): ProductCategorySaveRequest {
const error = categoryDraftError(draft)
if (error) throw new Error(error)
const request: ProductCategorySaveRequest = { name: draft.name.trim() }
if (typeof draft.parentId === 'number' && Number.isFinite(draft.parentId) && draft.parentId >= 1) {
request.parentId = Math.floor(draft.parentId)
}
const sortOrder = parseSortOrder(draft.sortOrder)
if (sortOrder !== null) request.sortOrder = sortOrder
const description = typeof draft.description === 'string' ? draft.description.trim() : ''
if (description) request.description = description
return request
}
@@ -0,0 +1,10 @@
/** 商品类目导出 URL 构建(任务 79):keyword 非空时带查询参数,纯逻辑。 */
export const PRODUCT_CATEGORY_EXPORT_ENDPOINT = '/api/admin/product-categories/export'
/** 导出下载地址:浏览器直接访问该 URL 触发流式下载。 */
export function buildProductCategoryExportUrl(keyword: string): string {
const trimmed = (keyword || '').trim()
if (!trimmed) return PRODUCT_CATEGORY_EXPORT_ENDPOINT
return `${PRODUCT_CATEGORY_EXPORT_ENDPOINT}?keyword=${encodeURIComponent(trimmed)}`
}
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
createProductCategoryDraft,
categoryDraftError,
toCategorySaveRequest,
} from '../src/pages/asin/product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from '../src/pages/asin/product-category-export.ts'
test('test_task_079_product_category_editor_normal_primary_path', () => {
// 正常主路径:名称/父级/排序/备注 → 保存请求(camel, 名称 trim)。
const draft = createProductCategoryDraft()
draft.parentId = 3
draft.name = ' 厨房刀具 '
draft.sortOrder = '2'
draft.description = ' 锋利 '
assert.equal(categoryDraftError(draft), null)
assert.deepEqual(toCategorySaveRequest(draft), {
parentId: 3,
name: '厨房刀具',
sortOrder: 2,
description: '锋利',
})
})
test('test_task_079_product_category_editor_normal_variant_input', () => {
// 正常变体:顶级(无父级/无排序/无备注)仅提交名称。
const draft = { parentId: null, name: '收纳', sortOrder: '', description: '' }
assert.equal(categoryDraftError(draft), null)
assert.deepEqual(toCategorySaveRequest(draft), { name: '收纳' })
})
test('test_task_079_product_category_editor_normal_repeated_operation_is_idempotent', () => {
// 正常重复:校验与序列化不改输入、结果稳定。
const draft = { parentId: 1, name: ' A ', sortOrder: '1', description: '' }
assert.deepEqual(toCategorySaveRequest(draft), toCategorySaveRequest(draft))
assert.deepEqual(draft, { parentId: 1, name: ' A ', sortOrder: '1', description: '' })
})
test('test_task_079_product_category_editor_boundary_empty_input', () => {
// 边界空值:空名称被拒。
const draft = { parentId: null, name: ' ', sortOrder: '', description: '' }
assert.match(categoryDraftError(draft) || '', /名称/)
})
test('test_task_079_product_category_editor_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:排序为空省略;排序 0 保留;描述为空省略。
const noSort = toCategorySaveRequest({ parentId: null, name: 'X', sortOrder: '', description: '' })
assert.equal('sortOrder' in noSort, false)
const zero = toCategorySaveRequest({ parentId: null, name: 'X', sortOrder: '0', description: '' })
assert.equal(zero.sortOrder, 0)
assert.equal('description' in zero, false)
})
test('test_task_079_product_category_editor_invalid_input_rejected', () => {
// 异常输入:非法/负数排序被拒并给出可读错误。
assert.match(categoryDraftError({ parentId: null, name: 'X', sortOrder: 'abc', description: '' }) || '', /排序/)
assert.match(categoryDraftError({ parentId: null, name: 'X', sortOrder: '-1', description: '' }) || '', /排序/)
})
test('test_task_079_product_category_editor_dependency_failure_returns_actionable_message', () => {
// 依赖失败/保存走 adapterPOST /product-category + PUT /product-category/{id}。
const api = readSource('src/pages/asin/product-category-editor-api.ts')
assert.match(api, /product-category/)
assert.match(api, /http\.post/)
assert.match(api, /http\.put/)
assert.match(api, /createProductCategory/)
assert.match(api, /updateProductCategory/)
const mod = readSource('src/pages/asin/product-category-editor-model.ts')
assert.equal(/axios|http\./.test(mod), false, '类目编辑模型保持纯逻辑')
})
test('test_task_079_product_category_export_normal_and_boundary', () => {
// 正常/边界:导出 URL 带 keyword(编码)或不带参数。
const url = buildProductCategoryExportUrl(' 厨房 刀 ')
assert.match(url, /\/product-categories\/export\?keyword=/)
assert.match(url, /%20/)
assert.equal(buildProductCategoryExportUrl(' '), '/api/admin/product-categories/export')
})