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 }
}