task-79(ASIN 数据中心): 实现商品类目新增/编辑/导出
POST /api/admin/product-category + PUT /product-category/{id} 保存适配
(名称必填/排序可选非负整数校验), 导出 URL(keyword), 8 个契约测试。
This commit is contained in:
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -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', () => {
|
||||||
|
// 依赖失败/保存走 adapter:POST /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')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user