task-78(ASIN 数据中心): 实现商品类目树加载和搜索
GET /api/admin/product-categories(+keyword) 树(嵌套 children/path/level/ childCount)与 /product-categories/search 分页搜索解析, 9 个契约测试。
This commit is contained in:
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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', () => {
|
||||||
|
// 依赖失败/加载走 adapter:GET /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, '商品类目模型保持纯逻辑')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user