From a46c35f01df104782347141252e71613c8cf6191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 16:15:56 +0800 Subject: [PATCH] =?UTF-8?q?task-78(ASIN=20=E6=95=B0=E6=8D=AE=E4=B8=AD?= =?UTF-8?q?=E5=BF=83):=20=E5=AE=9E=E7=8E=B0=E5=95=86=E5=93=81=E7=B1=BB?= =?UTF-8?q?=E7=9B=AE=E6=A0=91=E5=8A=A0=E8=BD=BD=E5=92=8C=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/admin/product-categories(+keyword) 树(嵌套 children/path/level/ childCount)与 /product-categories/search 分页搜索解析, 9 个契约测试。 --- .../src/pages/asin/product-category-api.ts | 32 +++++ .../src/pages/asin/product-category-model.ts | 109 +++++++++++++++ admin-frontend-vue/tests/task-78.test.ts | 125 ++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 admin-frontend-vue/src/pages/asin/product-category-api.ts create mode 100644 admin-frontend-vue/src/pages/asin/product-category-model.ts create mode 100644 admin-frontend-vue/tests/task-78.test.ts diff --git a/admin-frontend-vue/src/pages/asin/product-category-api.ts b/admin-frontend-vue/src/pages/asin/product-category-api.ts new file mode 100644 index 00000000..8d67ca81 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/product-category-api.ts @@ -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 { + const trimmed = keyword.trim() + const { data } = await http.get(PRODUCT_CATEGORY_ENDPOINT, { + params: trimmed ? { keyword: trimmed } : {}, + }) + return parseProductCategoryList(data) +} + +/** 分页搜索类目(keyword 空时返回空页)。 */ +export async function searchProductCategories( + keyword: string, + page: number, + pageSize: number, +): Promise { + const params: Record = { page, pageSize } + const trimmed = keyword.trim() + if (trimmed) params.keyword = trimmed + const { data } = await http.get(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params }) + return parseProductCategorySearchPage(data) +} diff --git a/admin-frontend-vue/src/pages/asin/product-category-model.ts b/admin-frontend-vue/src/pages/asin/product-category-model.ts new file mode 100644 index 00000000..4e06c9e5 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/product-category-model.ts @@ -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 + 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(payload) + const record = core && typeof core === 'object' ? (core as Record) : null + return { + tree: nodesOf(record?.tree), + items: nodesOf(record?.items), + } +} + +/** 解析类目搜索分页 VO;缺省字段回默认。 */ +export function parseProductCategorySearchPage(payload: unknown): ProductCategorySearchPage { + const core = unwrap(payload) + const record = core && typeof core === 'object' ? (core as Record) : 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 } +} diff --git a/admin-frontend-vue/tests/task-78.test.ts b/admin-frontend-vue/tests/task-78.test.ts new file mode 100644 index 00000000..f602ee86 --- /dev/null +++ b/admin-frontend-vue/tests/task-78.test.ts @@ -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, '商品类目模型保持纯逻辑') +})