align(商品类目): el-tree 节点补来源(内置/自定义)/排序/备注展示、行内编辑删除(有子禁用)、按需懒加载子级+加载更多(已加载x/y)、搜索表格补来源列、按钮名/占位对齐(对齐 admin.js renderProductCategoryRows/loadProductCategoryChildren)

This commit is contained in:
2026-09-05 23:18:27 +08:00
parent a8d08c4d39
commit 603822a6c1
5 changed files with 303 additions and 15 deletions
@@ -1,10 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
/** 商品类目:对齐 admin panel-product-categories —— 树(关键字搜索态独立分页/加载更多)、导出、删除(有子禁用)、新增/编辑弹窗语义。 */ /** 商品类目:对齐 admin panel-product-categories —— 树节点带 来源/排序/备注 + 行内编辑删除(有子禁用)、
* 按需懒加载子级(「加载更多 已加载 x/y」)、搜索态独立分页/加载更多、导出、新增/编辑弹窗语义。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchProductCategoryTree, searchProductCategories } from './product-category-api.ts' import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
import { createProductCategory, deleteProductCategory, updateProductCategory } from './product-category-editor-api.ts' import { createProductCategory, deleteProductCategory, updateProductCategory } from './product-category-editor-api.ts'
import { PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE, type ProductCategoryNode } from './product-category-model.ts' import {
appendCategoryChildren,
isCategoryLoadMoreNode,
makeCategoryLoadMoreNode,
PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE,
productCategoryNodeMeta,
productCategorySourceLabel,
type ProductCategoryNode,
} from './product-category-model.ts'
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts' import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from './product-category-export.ts' import { buildProductCategoryExportUrl } from './product-category-export.ts'
@@ -24,12 +33,22 @@ const editingId = ref<number | null>(null)
const saving = ref(false) const saving = ref(false)
const draft = reactive<ProductCategoryDraft>(createProductCategoryDraft()) const draft = reactive<ProductCategoryDraft>(createProductCategoryDraft())
/** 每父级(根=null)的子级分页状态:page/total/hasMore/loading,支撑「加载更多 已加载 x/y」。 */
const childrenState = new Map<string, { page: number; total: number; hasMore: boolean; loading: boolean }>()
const ROOT_KEY = '__root__'
function stateKeyOf(parentId: number | null): string {
return parentId == null ? ROOT_KEY : String(parentId)
}
function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] { function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
const out: ProductCategoryNode[] = [] const out: ProductCategoryNode[] = []
for (const node of nodes || []) { for (const node of nodes || []) {
if (!isCategoryLoadMoreNode(node)) {
out.push(node) out.push(node)
out.push(...flatten(node.children)) out.push(...flatten(node.children))
} }
}
return out return out
} }
@@ -37,10 +56,16 @@ const allNodes = computed(() => flatten(tree.value))
const searching = computed(() => (keyword.value || '').trim().length > 0) const searching = computed(() => (keyword.value || '').trim().length > 0)
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目')) const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
/** 拉取根级第一页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
async function loadTree(): Promise<void> { async function loadTree(): Promise<void> {
loading.value = true loading.value = true
try { try {
tree.value = (await fetchProductCategoryTree('')).tree const result = await fetchProductCategoryChildren(null, 1, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
childrenState.set(ROOT_KEY, state)
tree.value = result.hasMore
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
: result.items
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目加载失败') ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
} finally { } finally {
@@ -48,6 +73,62 @@ async function loadTree(): Promise<void> {
} }
} }
/** 节点展开时按需加载子级第一页(childCount>0 且未加载过才拉取)。 */
async function onNodeExpand(node: ProductCategoryNode): Promise<void> {
if (isCategoryLoadMoreNode(node) || node.childCount <= 0 || node.children.length > 0) return
await loadChildrenPage(node, 1, false)
}
/** 加载某父级一页子级(append=false 重置;append=true 追加并重建「加载更多」节点)。 */
async function loadChildrenPage(parent: ProductCategoryNode | null, page: number, append: boolean): Promise<void> {
const key = stateKeyOf(parent?.id ?? null)
const state = childrenState.get(key)
if (state?.loading) return
childrenState.set(key, { page: state?.page ?? 0, total: state?.total ?? 0, hasMore: state?.hasMore ?? false, loading: true })
try {
const result = await fetchProductCategoryChildren(parent?.id ?? null, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const loaded = append ? (state?.page ?? 0) + result.items.length : result.items.length
const nextState = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
childrenState.set(key, nextState)
if (parent) {
const current = parent.children || []
parent.children = append
? appendCategoryChildren(current, result.items, parent.id, loaded, result.total, result.hasMore)
: (result.hasMore
? [...result.items, makeCategoryLoadMoreNode(parent.id, result.items.length, result.total)]
: result.items)
} else {
const current = tree.value
tree.value = append
? appendCategoryChildren(current, result.items, null, loaded, result.total, result.hasMore)
: (result.hasMore
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
: result.items)
}
} catch (error) {
const fallback = childrenState.get(key) ?? { page: 0, total: 0, hasMore: false, loading: false }
fallback.loading = false
childrenState.set(key, fallback)
ElMessage.error(error instanceof Error ? error.message : '子类目加载失败')
}
}
/** 点击「加载更多」合成节点:翻下一页追加。 */
async function loadMoreChildren(more: ProductCategoryNode): Promise<void> {
if (!isCategoryLoadMoreNode(more)) return
const parentId = more.parentId
const parent = parentId == null ? null : allNodes.value.find((node) => node.id === parentId) ?? null
if (parentId != null && !parent) return
const state = childrenState.get(stateKeyOf(parentId))
if (state?.loading) return
await loadChildrenPage(parent, (state?.page ?? 0) + 1, true)
}
/** 「加载更多」按钮的加载态:取其父级分页状态的 loading。 */
function moreLoading(more: ProductCategoryNode): boolean {
return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false)
}
async function runSearch(page: number): Promise<void> { async function runSearch(page: number): Promise<void> {
searchLoading.value = true searchLoading.value = true
try { try {
@@ -99,12 +180,16 @@ function openEdit(node: ProductCategoryNode) {
} }
function onNodeClick(node: ProductCategoryNode) { function onNodeClick(node: ProductCategoryNode) {
if (isCategoryLoadMoreNode(node)) {
void loadMoreChildren(node)
return
}
selected.value = node selected.value = node
} }
function canDelete(node: ProductCategoryNode | null): boolean { function canDelete(node: ProductCategoryNode | null): boolean {
if (!node) return false if (!node || isCategoryLoadMoreNode(node)) return false
return !(node.childCount > 0 || (Array.isArray(node.children) && node.children.length > 0)) return !(node.childCount > 0 || (Array.isArray(node.children) && node.children.some((child) => !isCategoryLoadMoreNode(child))))
} }
async function removeCategory(node: ProductCategoryNode): Promise<void> { async function removeCategory(node: ProductCategoryNode): Promise<void> {
@@ -176,14 +261,14 @@ onMounted(loadTree)
<p>维护商品类目树与类目名称/说明有子类目节点不可删除</p> <p>维护商品类目树与类目名称/说明有子类目节点不可删除</p>
</div> </div>
<div class="actions"> <div class="actions">
<el-button type="primary" @click="openCreate(null)">新增类目</el-button> <el-button type="primary" @click="openCreate(null)">新增类目</el-button>
<el-button @click="exportCategories">导出</el-button> <el-button @click="exportCategories">导出</el-button>
</div> </div>
</div> </div>
<el-card shadow="never" style="margin-bottom: 14px"> <el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-row"> <div class="filter-row">
<el-input v-model="keyword" placeholder="搜索类目名称(保留父路径)" clearable style="width: 280px" @keyup.enter="apply" /> <el-input v-model="keyword" placeholder="搜索类目名称、编码或备注" clearable style="width: 280px" @keyup.enter="apply" />
<el-button type="primary" @click="apply">搜索</el-button> <el-button type="primary" @click="apply">搜索</el-button>
<el-button @click="reset">清空</el-button> <el-button @click="reset">清空</el-button>
</div> </div>
@@ -195,16 +280,42 @@ onMounted(loadTree)
:data="tree" :data="tree"
node-key="id" node-key="id"
:props="{ label: 'name', children: 'children' }" :props="{ label: 'name', children: 'children' }"
default-expand-all :expand-on-click-node="false"
highlight-current highlight-current
@node-click="onNodeClick" @node-click="onNodeClick"
/> @node-expand="onNodeExpand"
>
<template #default="{ data }">
<template v-if="isCategoryLoadMoreNode(data)">
<span class="node-more" @click.stop="loadMoreChildren(data)">
<el-button link size="small" :loading="moreLoading(data)">加载更多</el-button>
<span class="dim">已加载 {{ data.loadedCount }} / {{ data.totalCount }}</span>
</span>
</template>
<span v-else class="node-row">
<span class="node-main">
<span class="node-name">{{ data.name }}</span>
<el-tag size="small" effect="plain" :type="data.isBuiltin ? 'info' : 'success'">{{ productCategorySourceLabel(data) }}</el-tag>
<span class="node-meta">{{ productCategoryNodeMeta(data) }}</span>
<span v-if="data.description" class="node-desc" :title="data.description">{{ data.description }}</span>
</span>
<span class="node-ops" @click.stop>
<el-button link size="small" type="primary" @click="openEdit(data as ProductCategoryNode)">编辑</el-button>
<el-button
link
size="small"
type="danger"
:disabled="!canDelete(data as ProductCategoryNode)"
@click="removeCategory(data as ProductCategoryNode)"
>删除</el-button>
</span>
</span>
</template>
</el-tree>
<div class="tree-actions"> <div class="tree-actions">
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span> <span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
<span class="btn-group"> <span class="btn-group">
<el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button> <el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button>
<el-button size="small" type="primary" :disabled="!selected" @click="openEdit(selected as ProductCategoryNode)">编辑</el-button>
<el-button size="small" type="danger" :disabled="!canDelete(selected)" @click="removeCategory(selected as ProductCategoryNode)">删除</el-button>
</span> </span>
</div> </div>
</el-card> </el-card>
@@ -222,6 +333,13 @@ onMounted(loadTree)
<el-table-column label="排序" width="90"> <el-table-column label="排序" width="90">
<template #default="{ row }">{{ (row as ProductCategoryNode).sortOrder ?? '—' }}</template> <template #default="{ row }">{{ (row as ProductCategoryNode).sortOrder ?? '—' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="来源" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain" :type="(row as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
{{ productCategorySourceLabel(row as ProductCategoryNode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" min-width="180"> <el-table-column label="说明" min-width="180">
<template #default="{ row }">{{ (row as ProductCategoryNode).description || '—' }}</template> <template #default="{ row }">{{ (row as ProductCategoryNode).description || '—' }}</template>
</el-table-column> </el-table-column>
@@ -4,6 +4,7 @@ import {
parseProductCategoryList, parseProductCategoryList,
parseProductCategorySearchPage, parseProductCategorySearchPage,
type ProductCategoryListResult, type ProductCategoryListResult,
type ProductCategoryNode,
type ProductCategorySearchPage, type ProductCategorySearchPage,
} from './product-category-model' } from './product-category-model'
@@ -30,3 +31,24 @@ export async function searchProductCategories(
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params }) const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
return parseProductCategorySearchPage(data) return parseProductCategorySearchPage(data)
} }
/** 子级类目分页页(对齐 admin.js loadProductCategoryChildren 的 /children 端点语义)。 */
export interface ProductCategoryChildrenPage {
items: ProductCategoryNode[]
total: number
page: number
hasMore: boolean
}
/** 懒加载某父级的子类目页(parentId 为空表示根级),解析 page/total/hasMore。 */
export async function fetchProductCategoryChildren(
parentId: number | null,
page: number,
pageSize: number,
): Promise<ProductCategoryChildrenPage> {
const params: Record<string, string | number> = { page, page_size: pageSize }
if (parentId != null) params.parent_id = parentId
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/children`, { params })
const parsed = parseProductCategorySearchPage(data)
return { items: parsed.items, total: parsed.total, page: parsed.page, hasMore: parsed.hasMore }
}
@@ -107,3 +107,63 @@ export function parseProductCategorySearchPage(payload: unknown): ProductCategor
const hasMore = typeof record?.hasMore === 'boolean' ? record.hasMore : page * pageSize < total const hasMore = typeof record?.hasMore === 'boolean' ? record.hasMore : page * pageSize < total
return { items, total, page, pageSize, hasMore } return { items, total, page, pageSize, hasMore }
} }
/** 来源展示标签(对齐 admin.js:6004 内置/自定义 tag)。 */
export function productCategorySourceLabel(node: Pick<ProductCategoryNode, 'isBuiltin'> | null | undefined): string {
return node?.isBuiltin ? '内置' : '自定义'
}
/** 节点元信息文案:来源 + 排序(对齐参考 来源列/排序列)。 */
export function productCategoryNodeMeta(node: Pick<ProductCategoryNode, 'isBuiltin' | 'sortOrder'>): string {
return `${productCategorySourceLabel(node)} · 排序 ${node.sortOrder ?? 0}`
}
/** 合成"加载更多"节点:挂在同级 children 末尾,点击翻下一页(对齐 admin.js 的 load-more 行)。 */
export interface CategoryLoadMoreNode extends ProductCategoryNode {
isLoadMore: true
loadedCount: number
totalCount: number
}
export function isCategoryLoadMoreNode(node: ProductCategoryNode | null | undefined): node is CategoryLoadMoreNode {
return !!node && (node as Partial<CategoryLoadMoreNode>).isLoadMore === true
}
export function makeCategoryLoadMoreNode(
parentId: number | null,
loadedCount: number,
totalCount: number,
): CategoryLoadMoreNode {
const syntheticId = parentId ? -parentId : -1
return {
id: syntheticId,
parentId,
name: '',
categoryKey: '',
sortOrder: null,
description: '',
isBuiltin: false,
childCount: 0,
level: null,
path: '',
children: [],
isLoadMore: true,
loadedCount,
totalCount,
}
}
/** 把新一页并入同级 children:去掉旧"加载更多"节点,追加新条目;仍有下一页时补新"加载更多"节点。 */
export function appendCategoryChildren(
current: ProductCategoryNode[],
nextItems: ProductCategoryNode[],
parentId: number | null,
loadedCount: number,
totalCount: number,
hasMore: boolean,
): ProductCategoryNode[] {
const base = current.filter((node) => !isCategoryLoadMoreNode(node))
const merged = [...base, ...nextItems]
if (hasMore) merged.push(makeCategoryLoadMoreNode(parentId, loadedCount, totalCount))
return merged
}
@@ -0,0 +1,87 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
appendCategoryChildren,
isCategoryLoadMoreNode,
makeCategoryLoadMoreNode,
productCategoryNodeMeta,
productCategorySourceLabel,
type ProductCategoryNode,
} from '../src/pages/asin/product-category-model.ts'
/** 对齐 admin.js:5998-6012/6077-6120:树节点带来源/排序/备注、按需懒加载 + 加载更多(已加载 x/y)。 */
function node(id: number, overrides: Partial<ProductCategoryNode> = {}): ProductCategoryNode {
return {
id,
parentId: null,
name: `类目${id}`,
categoryKey: `k${id}`,
sortOrder: id,
description: '',
isBuiltin: false,
childCount: 0,
level: 0,
path: `类目${id}`,
children: [],
...overrides,
}
}
test('align_category_source_label', () => {
assert.equal(productCategorySourceLabel({ isBuiltin: true } as ProductCategoryNode), '内置')
assert.equal(productCategorySourceLabel({ isBuiltin: false } as ProductCategoryNode), '自定义')
assert.equal(productCategorySourceLabel(null), '自定义')
})
test('align_category_node_meta_contains_source_and_sort', () => {
assert.equal(productCategoryNodeMeta({ isBuiltin: true, sortOrder: 3 } as ProductCategoryNode), '内置 · 排序 3')
assert.equal(productCategoryNodeMeta({ isBuiltin: false, sortOrder: null } as ProductCategoryNode), '自定义 · 排序 0')
})
test('align_category_load_more_node_factory', () => {
const more = makeCategoryLoadMoreNode(5, 20, 45)
assert.equal(isCategoryLoadMoreNode(more), true)
assert.equal(more.parentId, 5)
assert.equal(more.loadedCount, 20)
assert.equal(more.totalCount, 45)
assert.equal(isCategoryLoadMoreNode(node(1)), false)
})
test('align_category_append_children_keeps_more_until_done', () => {
const current = [node(1), makeCategoryLoadMoreNode(null, 20, 45)]
const next = appendCategoryChildren(current, [node(2), node(3)], null, 40, 45, true)
assert.deepEqual(next.filter((n) => !isCategoryLoadMoreNode(n)).map((n) => n.id), [1, 2, 3])
const tail = next[next.length - 1]
assert.equal(isCategoryLoadMoreNode(tail), true)
assert.equal((tail as ReturnType<typeof makeCategoryLoadMoreNode>).loadedCount, 40)
})
test('align_category_append_children_drops_more_when_complete', () => {
const current = [node(1), makeCategoryLoadMoreNode(9, 20, 22)]
const next = appendCategoryChildren(current, [node(2), node(3)], 9, 22, 22, false)
assert.deepEqual(next.map((n) => n.id), [1, 2, 3])
assert.equal(next.some((n) => isCategoryLoadMoreNode(n)), false)
})
test('align_category_children_api_wiring', () => {
const api = readSource('src/pages/asin/product-category-api.ts')
assert.match(api, /children/, '懒加载走 children 端点')
assert.match(api, /parent_id|parentId/, '带父级参数')
assert.match(api, /page_size|pageSize/, '分页参数')
})
test('align_category_page_tree_and_search_wiring', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.doesNotMatch(page, /default-expand-all/, '树不再全量展开,改按需懒加载')
assert.match(page, /加载更多/, '节点带加载更多')
assert.match(page, /已加载/, '显示已加载 x/y')
assert.match(page, /productCategorySourceLabel/, '节点展示来源')
assert.match(page, /排序/, '节点展示排序')
assert.doesNotMatch(page, /新增根类目/, '按钮名对齐参考“新增类目”')
assert.match(page, /新增类目/, '顶部新增类目按钮')
assert.match(page, /搜索类目名称、编码或备注/, '搜索占位对齐参考')
assert.match(page, /来源/, '搜索表格补来源列')
assert.match(page, /childCount/, '删除按钮按有子禁用')
})
+2 -1
View File
@@ -28,7 +28,8 @@ test('test_task_256_category_normal_repeated_operation_is_idempotent', () => {
test('test_task_256_category_boundary_empty_input', () => { test('test_task_256_category_boundary_empty_input', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue') const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /新增类目/, '需保留新增类目') // 对齐 admin.html:顶部按钮名「新增类目」;选中节点后仍可新增类目
assert.match(page, /新增类目/, '顶部新增类目入口')
assert.match(page, /新增子类目/, '需保留新增子类目') assert.match(page, /新增子类目/, '需保留新增子类目')
}) })