From 6d9b4a13ee98ada8efdcb8819b5cb0576afbc63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 6 Sep 2026 02:19:45 +0800 Subject: [PATCH] =?UTF-8?q?task-277(=E9=AA=8C=E6=94=B6=E5=8F=8D=E9=A6=88):?= =?UTF-8?q?=20=E7=B1=BB=E7=9B=AE=E6=A0=91=E4=BC=98=E5=8C=96(=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E5=AD=90=E7=BA=A7=E4=BF=9D=E8=AF=81=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E7=AE=AD=E5=A4=B4/28px=E7=BC=A9=E8=BF=9B/=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=8A=A0=E9=97=B4=E8=B7=9D/=E7=AE=AD=E5=A4=B4=E6=94=BE?= =?UTF-8?q?=E5=A4=A7=E5=8A=A0=E8=89=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/asin/ProductCategoryPage.vue | 29 ++++++++++-- .../src/pages/asin/product-category-model.ts | 33 +++++++++++++- .../tests/align-category-tree.test.ts | 44 +++++++++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 admin-frontend-vue/tests/align-category-tree.test.ts diff --git a/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue b/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue index 39582c62..50771846 100644 --- a/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue +++ b/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue @@ -8,6 +8,7 @@ import { createProductCategory, deleteProductCategory, updateProductCategory } f import { appendCategoryChildren, isCategoryLoadMoreNode, + isCategoryPlaceholderNode, makeCategoryLoadMoreNode, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE, productCategoryNodeMeta, @@ -44,7 +45,7 @@ function stateKeyOf(parentId: number | null): string { function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] { const out: ProductCategoryNode[] = [] for (const node of nodes || []) { - if (!isCategoryLoadMoreNode(node)) { + if (!isCategoryLoadMoreNode(node) && !isCategoryPlaceholderNode(node)) { out.push(node) out.push(...flatten(node.children)) } @@ -73,9 +74,11 @@ async function loadTree(): Promise { } } -/** 节点展开时按需加载子级第一页(childCount>0 且未加载过才拉取)。 */ +/** 节点展开时按需加载子级第一页(childCount>0 且未加载过真实子级才拉取;占位子级不算已加载)。 */ async function onNodeExpand(node: ProductCategoryNode): Promise { - if (isCategoryLoadMoreNode(node) || node.childCount <= 0 || node.children.length > 0) return + if (isCategoryLoadMoreNode(node) || isCategoryPlaceholderNode(node) || node.childCount <= 0) return + const hasRealChildren = node.children.some((child) => !isCategoryPlaceholderNode(child) && !isCategoryLoadMoreNode(child)) + if (hasRealChildren) return await loadChildrenPage(node, 1, false) } @@ -282,6 +285,7 @@ onMounted(loadTree) node-key="id" :props="{ label: 'name', children: 'children' }" :expand-on-click-node="false" + :indent="28" highlight-current @node-click="onNodeClick" @node-expand="onNodeExpand" @@ -293,6 +297,7 @@ onMounted(loadTree) 已加载 {{ data.loadedCount }} / {{ data.totalCount }} + 加载子级中… {{ data.name }} @@ -390,4 +395,22 @@ onMounted(loadTree) .btn-group { display: inline-flex; gap: 6px; } .search-head { display: flex; justify-content: space-between; margin-bottom: 12px; } .load-more-row { text-align: center; margin-top: 12px; } +/* 类目树节点:加大间距不再紧凑,父子级缩进对齐旧版树形表格。 */ +.node-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 8px; width: 100%; min-width: 0; } +.node-main { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; } +.node-name { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; } +.node-meta { color: var(--admin-muted); font-size: 12px; white-space: nowrap; } +.node-desc { color: var(--el-text-color-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.node-ops { flex: none; } +.node-placeholder { display: inline-block; padding: 7px 8px; color: var(--el-text-color-placeholder); font-size: 12px; } +.node-more { display: inline-flex; align-items: center; gap: 8px; padding: 4px 8px; } +/* 展开/折叠箭头放大加色(对齐旧版 +/- 折叠标记的可见度)。 */ +:deep(.el-tree-node__expand-icon) { + font-size: 18px; + color: var(--admin-primary); +} +:deep(.el-tree-node__content) { + height: auto; + min-height: 34px; +} diff --git a/admin-frontend-vue/src/pages/asin/product-category-model.ts b/admin-frontend-vue/src/pages/asin/product-category-model.ts index e0ef0c9b..5749607a 100644 --- a/admin-frontend-vue/src/pages/asin/product-category-model.ts +++ b/admin-frontend-vue/src/pages/asin/product-category-model.ts @@ -58,6 +58,7 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null const record = raw as Record const id = int(record.id) if (id === null) return null + const childCount = int(record.childCount ?? record.child_count) ?? 0 const node: ProductCategoryNode = { id, parentId: int(record.parentId ?? record.parent_id), @@ -66,7 +67,7 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null 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, + childCount, level: int(record.level), path: text(record.path), children: [], @@ -76,6 +77,10 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null .map((child) => toProductCategoryNode(child)) .filter((child): child is ProductCategoryNode => child !== null) } + // 有子级但未返回 children 数据:注入占位子级,让 el-tree 显示展开箭头(懒加载入口)。 + if (node.children.length === 0 && childCount > 0) { + node.children = [makeCategoryPlaceholderNode(id)] + } return node } @@ -129,6 +134,32 @@ export function isCategoryLoadMoreNode(node: ProductCategoryNode | null | undefi return !!node && (node as Partial).isLoadMore === true } +/** 合成"占位"节点:childCount>0 但子级未加载时占位,保证 el-tree 显示展开箭头(懒加载入口)。 */ +export interface CategoryPlaceholderNode extends ProductCategoryNode { + isPlaceholder: true +} + +export function isCategoryPlaceholderNode(node: ProductCategoryNode | null | undefined): node is CategoryPlaceholderNode { + return !!node && (node as Partial).isPlaceholder === true +} + +export function makeCategoryPlaceholderNode(parentId: number | null): CategoryPlaceholderNode { + return { + id: -(parentId ?? 0) - 1_000_000, + parentId, + name: '…', + categoryKey: '', + sortOrder: null, + description: '', + isBuiltin: false, + childCount: 0, + level: null, + path: '', + children: [], + isPlaceholder: true, + } +} + export function makeCategoryLoadMoreNode( parentId: number | null, loadedCount: number, diff --git a/admin-frontend-vue/tests/align-category-tree.test.ts b/admin-frontend-vue/tests/align-category-tree.test.ts new file mode 100644 index 00000000..194b0d31 --- /dev/null +++ b/admin-frontend-vue/tests/align-category-tree.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { isCategoryPlaceholderNode, toProductCategoryNode, makeCategoryPlaceholderNode } from '../src/pages/asin/product-category-model.ts' + +// 验收反馈:类目树太紧凑、没有折叠展开、父子级效果对齐旧版(旧版为缩进树形表格带 +/- 展开)。 +// 不变式:childCount>0 的未加载节点带占位子级(children 非空),el-tree 因此显示展开箭头; +// 节点间距加大(:indent 28 + node-row padding),父子级视觉与旧版缩进树一致。 + +test('align_category_tree_normal_primary_path', () => { + // 有子级但未返回 children 数据时,解析为带占位子级的节点(el-tree 显示展开箭头)。 + const node = toProductCategoryNode({ id: 5, name: '父类目', child_count: 3, path: '父类目' }) + assert.ok(node && node.children.length > 0, 'childCount>0 节点需带占位子级以显示展开箭头') + assert.ok(node && node.children.every((child) => isCategoryPlaceholderNode(child)), '占位子级可识别') +}) + +test('align_category_tree_normal_variant_input', () => { + // 占位节点 id 与真实节点不冲突(负数域),且不可被误认为真实类目。 + const placeholder = makeCategoryPlaceholderNode(5) + assert.ok(placeholder.id < 0, '占位节点 id 用负数域避免与真实 id 冲突') + assert.ok(isCategoryPlaceholderNode(placeholder)) + assert.equal(isCategoryPlaceholderNode({ id: 1, parentId: null, name: '真实', categoryKey: '', sortOrder: 0, description: '', isBuiltin: false, childCount: 0, level: 0, path: '', children: [] }), false) +}) + +test('align_category_tree_boundary_empty_input', () => { + // 无子级节点保持叶子形态(children 空)。 + const leaf = toProductCategoryNode({ id: 9, name: '叶子', child_count: 0, path: '叶子' }) + assert.ok(leaf && leaf.children.length === 0, '叶子节点无占位子级') +}) + +test('align_category_tree_boundary_single_item', () => { + const page = readSource('src/pages/asin/ProductCategoryPage.vue') + assert.match(page, /:indent="28"/, '树缩进加大(28px/级)') + assert.match(page, /isCategoryPlaceholderNode/, '模板渲染占位节点分支') + // 展开加载需识别"只有占位子级"的节点(children 全占位也触发懒加载)。 + assert.match(page, /some\(.*isCategoryPlaceholderNode/, '展开判断兼容占位子级') +}) + +test('align_category_tree_dependency_failure_returns_actionable_message', () => { + // 视觉:节点行加 padding 不再紧凑;箭头样式放大加色。 + const page = readSource('src/pages/asin/ProductCategoryPage.vue') + assert.match(page, /\.node-row\s*\{[^}]*padding/, '节点行有 padding') + assert.match(page, /el-tree-node__expand-icon|expand-icon/, '展开图标样式定制') +})