task-277(验收反馈): 类目树优化(占位子级保证展开箭头/28px缩进/节点加间距/箭头放大加色)
This commit is contained in:
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 节点展开时按需加载子级第一页(childCount>0 且未加载过才拉取)。 */
|
||||
/** 节点展开时按需加载子级第一页(childCount>0 且未加载过真实子级才拉取;占位子级不算已加载)。 */
|
||||
async function onNodeExpand(node: ProductCategoryNode): Promise<void> {
|
||||
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)
|
||||
<span class="dim">已加载 {{ data.loadedCount }} / {{ data.totalCount }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="isCategoryPlaceholderNode(data)" class="node-placeholder" @click.stop>加载子级中…</span>
|
||||
<span v-else class="node-row">
|
||||
<span class="node-main">
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -58,6 +58,7 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null
|
||||
const record = raw as Record<string, unknown>
|
||||
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<CategoryLoadMoreNode>).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<CategoryPlaceholderNode>).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,
|
||||
|
||||
@@ -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/, '展开图标样式定制')
|
||||
})
|
||||
Reference in New Issue
Block a user