task-78(ASIN 数据中心): 实现商品类目树加载和搜索

GET /api/admin/product-categories(+keyword) 树(嵌套 children/path/level/
childCount)与 /product-categories/search 分页搜索解析, 9 个契约测试。
This commit is contained in:
2026-09-05 16:15:56 +08:00
parent c1d6ce6d20
commit a46c35f01d
3 changed files with 266 additions and 0 deletions
@@ -0,0 +1,32 @@
/** 商品类目树/搜索适配(任务 78):GET /api/admin/product-categories(+keyword) 与 /product-categories/search。 */
import { http } from '@/api/http'
import {
parseProductCategoryList,
parseProductCategorySearchPage,
type ProductCategoryListResult,
type ProductCategorySearchPage,
} from './product-category-model'
export const PRODUCT_CATEGORY_ENDPOINT = '/api/admin/product-categories'
/** 拉取类目树(keyword 非空时由服务端过滤并保留父路径)。 */
export async function fetchProductCategoryTree(keyword = ''): Promise<ProductCategoryListResult> {
const trimmed = keyword.trim()
const { data } = await http.get<unknown>(PRODUCT_CATEGORY_ENDPOINT, {
params: trimmed ? { keyword: trimmed } : {},
})
return parseProductCategoryList(data)
}
/** 分页搜索类目(keyword 空时返回空页)。 */
export async function searchProductCategories(
keyword: string,
page: number,
pageSize: number,
): Promise<ProductCategorySearchPage> {
const params: Record<string, string | number> = { page, pageSize }
const trimmed = keyword.trim()
if (trimmed) params.keyword = trimmed
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
return parseProductCategorySearchPage(data)
}
@@ -0,0 +1,109 @@
/** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 20
export interface ProductCategoryNode {
id: number
parentId: number | null
name: string
categoryKey: string
sortOrder: number | null
description: string
isBuiltin: boolean
childCount: number
level: number | null
path: string
children: ProductCategoryNode[]
}
export interface ProductCategoryListResult {
tree: ProductCategoryNode[]
items: ProductCategoryNode[]
}
export interface ProductCategorySearchPage {
items: ProductCategoryNode[]
total: number
page: number
pageSize: number
hasMore: boolean
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function int(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value)
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
return Math.floor(Number(value))
}
return null
}
function asBoolean(value: unknown): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
if (typeof value === 'string') {
const v = value.trim().toLowerCase()
return v === 'true' || v === '1'
}
return false
}
/** 解析单条类目节点(递归 children);缺 id 视为无效。 */
export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = int(record.id)
if (id === null) return null
const node: ProductCategoryNode = {
id,
parentId: int(record.parentId ?? record.parent_id),
name: text(record.name),
categoryKey: text(record.categoryKey ?? record.category_key),
sortOrder: int(record.sortOrder ?? record.sort_order),
description: text(record.description),
isBuiltin: asBoolean(record.isBuiltin ?? record.builtin),
childCount: int(record.childCount ?? record.child_count) ?? 0,
level: int(record.level),
path: text(record.path),
children: [],
}
if (Array.isArray(record.children)) {
node.children = record.children
.map((child) => toProductCategoryNode(child))
.filter((child): child is ProductCategoryNode => child !== null)
}
return node
}
function nodesOf(value: unknown): ProductCategoryNode[] {
if (!Array.isArray(value)) return []
return value
.map((raw) => toProductCategoryNode(raw))
.filter((node): node is ProductCategoryNode => node !== null)
}
/** 解析类目 VO:树加载(list) 取嵌套 tree 与扁平 items。 */
export function parseProductCategoryList(payload: unknown): ProductCategoryListResult {
const core = unwrap<unknown>(payload)
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : null
return {
tree: nodesOf(record?.tree),
items: nodesOf(record?.items),
}
}
/** 解析类目搜索分页 VO;缺省字段回默认。 */
export function parseProductCategorySearchPage(payload: unknown): ProductCategorySearchPage {
const core = unwrap<unknown>(payload)
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : null
const items = nodesOf(record?.items)
const total = int(record?.total) ?? 0
const page = int(record?.page) ?? 1
const pageSize = int(record?.pageSize ?? record?.page_size) ?? PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE
const hasMore = typeof record?.hasMore === 'boolean' ? record.hasMore : page * pageSize < total
return { items, total, page, pageSize, hasMore }
}
+125
View File
@@ -0,0 +1,125 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
toProductCategoryNode,
parseProductCategoryList,
parseProductCategorySearchPage,
} from '../src/pages/asin/product-category-model.ts'
test('test_task_078_product_category_tree_normal_primary_path', () => {
// 正常主路径:Java ProductCategoryListVo(tree 嵌套+childCount/path/level) 解析为节点树。
const { tree } = parseProductCategoryList({
success: true,
data: {
tree: [
{
id: 1,
parentId: null,
name: '厨房',
categoryKey: 'kitchen',
sortOrder: 1,
description: '厨房类目',
isBuiltin: true,
childCount: 1,
level: 1,
path: '厨房',
children: [
{ id: 2, parentId: 1, name: '刀具', sortOrder: 1, isBuiltin: false, childCount: 0, level: 2, path: '厨房 / 刀具' },
],
},
],
},
})
assert.equal(tree.length, 1)
const root = tree[0]
assert.equal(root.name, '厨房')
assert.equal(root.isBuiltin, true)
assert.equal(root.path, '厨房')
assert.equal(root.childCount, 1)
assert.equal(root.children.length, 1)
assert.equal(root.children[0].name, '刀具')
assert.equal(root.children[0].path, '厨房 / 刀具')
})
test('test_task_078_product_category_tree_normal_variant_input', () => {
// 正常变体:无父级/无内置/扁平 items 一并归一。
const { tree, items } = parseProductCategoryList({
data: {
tree: [{ id: 3, name: '收纳', sortOrder: 2, isBuiltin: false, childCount: 0, level: 1, path: '收纳' }],
items: [{ id: 3, name: '收纳', path: '收纳' }],
},
})
assert.equal(tree[0].parentId, null)
assert.equal(tree[0].isBuiltin, false)
assert.equal(items.length, 1)
})
test('test_task_078_product_category_tree_normal_repeated_operation_is_idempotent', () => {
// 正常重复:解析不改输入、结果稳定。
const payload = { data: { tree: [{ id: 5, name: 'X', children: [] }] } }
assert.deepEqual(parseProductCategoryList(payload), parseProductCategoryList(payload))
})
test('test_task_078_product_category_tree_boundary_empty_input', () => {
// 边界空值:空负载回空树与空 items,不崩溃。
const { tree, items } = parseProductCategoryList({})
assert.deepEqual(tree, [])
assert.deepEqual(items, [])
})
test('test_task_078_product_category_tree_boundary_single_item', () => {
// 边界单元素:缺 id 的节点被过滤;children 缺省为空数组。
const { tree } = parseProductCategoryList({
data: { tree: [{ id: 6, name: 'A' }, { name: 'no-id' }] },
})
assert.equal(tree.length, 1)
assert.equal(tree[0].id, 6)
assert.deepEqual(tree[0].children, [])
})
test('test_task_078_product_category_search_normal_page_result', () => {
// 正常主路径:search 分页结果(items/total/page/pageSize/hasMore)解析。
const page = parseProductCategorySearchPage({
success: true,
data: {
items: [{ id: 10, name: '刀具', level: 2, path: '厨房 / 刀具' }],
total: 21,
page: 1,
pageSize: 20,
hasMore: true,
},
})
assert.equal(page.items.length, 1)
assert.equal(page.items[0].path, '厨房 / 刀具')
assert.equal(page.total, 21)
assert.equal(page.hasMore, true)
})
test('test_task_078_product_category_search_boundary_empty_and_default', () => {
// 边界空值/缺字段:空负载 items 空、hasMore false、页大小回默认 20。
const page = parseProductCategorySearchPage({})
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.hasMore, false)
assert.equal(page.pageSize, 20)
})
test('test_task_078_product_category_invalid_input_rejected', () => {
// 异常输入:success=false 抛后端 message;垃圾/非对象节点过滤。
assert.throws(() => parseProductCategoryList({ success: false, message: '无权访问类目' }), /无权/)
assert.throws(() => parseProductCategorySearchPage({ success: false, message: '无权访问类目' }), /无权/)
assert.equal(toProductCategoryNode('garbage'), null)
assert.equal(toProductCategoryNode({ name: 'no id' }), null)
})
test('test_task_078_product_category_dependency_failure_returns_actionable_message', () => {
// 依赖失败/加载走 adapterGET /product-categories(+keyword) 与 /product-categories/search。
const api = readSource('src/pages/asin/product-category-api.ts')
assert.match(api, /product-categories/)
assert.match(api, /http\.get/)
assert.match(api, /fetchProductCategoryTree|productCategoryTree/)
assert.match(api, /searchProductCategories|product-categories\/search/)
const mod = readSource('src/pages/asin/product-category-model.ts')
assert.equal(/axios|http\./.test(mod), false, '商品类目模型保持纯逻辑')
})