task-256(admin.html观感对齐): 商品类目页对齐(删除有子禁用/搜索分页加载更多/导出/编辑弹窗语义)

This commit is contained in:
2026-09-05 21:30:53 +08:00
parent 178ff96be4
commit 85f565bd82
3 changed files with 334 additions and 0 deletions
@@ -0,0 +1,274 @@
<script setup lang="ts">
/** 商品类目:对齐 admin panel-product-categories —— 树(关键字搜索态独立分页/加载更多)、导出、删除(有子禁用)、新增/编辑弹窗语义。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchProductCategoryTree, searchProductCategories } from './product-category-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 { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from './product-category-export.ts'
const loading = ref(false)
const tree = ref<ProductCategoryNode[]>([])
const keyword = ref('')
const selected = ref<ProductCategoryNode | null>(null)
const searchLoading = ref(false)
const searchRows = ref<ProductCategoryNode[]>([])
const searchTotal = ref(0)
const searchPage = ref(1)
const searchHasMore = ref(false)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const draft = reactive<ProductCategoryDraft>(createProductCategoryDraft())
function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
const out: ProductCategoryNode[] = []
for (const node of nodes || []) {
out.push(node)
out.push(...flatten(node.children))
}
return out
}
const allNodes = computed(() => flatten(tree.value))
const searching = computed(() => (keyword.value || '').trim().length > 0)
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
async function loadTree(): Promise<void> {
loading.value = true
try {
tree.value = (await fetchProductCategoryTree('')).tree
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
} finally {
loading.value = false
}
}
async function runSearch(page: number): Promise<void> {
searchLoading.value = true
try {
const result = await searchProductCategories(keyword.value, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
searchRows.value = page === 1 ? result.items : [...searchRows.value, ...result.items]
searchTotal.value = result.total
searchPage.value = result.page
searchHasMore.value = result.hasMore
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目搜索失败')
} finally {
searchLoading.value = false
}
}
function apply(): void {
selected.value = null
if (searching.value) void runSearch(1)
else void loadTree()
}
function reset(): void {
keyword.value = ''
selected.value = null
searchRows.value = []
searchHasMore.value = false
void loadTree()
}
function loadMore(): void {
void runSearch(searchPage.value + 1)
}
function openCreate(parent: ProductCategoryNode | null) {
editingId.value = null
Object.assign(draft, createProductCategoryDraft())
draft.parentId = parent ? parent.id : null
dialogVisible.value = true
}
function openEdit(node: ProductCategoryNode) {
editingId.value = node.id
Object.assign(draft, createProductCategoryDraft())
draft.parentId = node.parentId
draft.name = node.name
draft.sortOrder = node.sortOrder == null ? '' : String(node.sortOrder)
draft.description = node.description
dialogVisible.value = true
}
function onNodeClick(node: ProductCategoryNode) {
selected.value = node
}
function canDelete(node: ProductCategoryNode | null): boolean {
if (!node) return false
return !(node.childCount > 0 || (Array.isArray(node.children) && node.children.length > 0))
}
async function removeCategory(node: ProductCategoryNode): Promise<void> {
if (!canDelete(node)) {
ElMessage.warning('存在子类目,不能删除')
return
}
try {
await ElMessageBox.confirm(`确定删除“${node.name}”?`, '删除类目', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
await deleteProductCategory(node.id)
ElMessage.success('已删除')
if (selected.value?.id === node.id) selected.value = null
if (searching.value) void runSearch(1)
else void loadTree()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
}
async function submit(): Promise<void> {
const error = categoryDraftError({ ...draft })
if (error) {
ElMessage.warning(error)
return
}
saving.value = true
try {
const body = toCategorySaveRequest({ ...draft })
if (editingId.value == null) {
await createProductCategory(body)
ElMessage.success('已新增')
} else {
await updateProductCategory(editingId.value, body)
ElMessage.success('已保存')
}
dialogVisible.value = false
if (searching.value) void runSearch(1)
else void loadTree()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '保存失败')
} finally {
saving.value = false
}
}
function exportCategories(): void {
const anchor = document.createElement('a')
anchor.href = buildProductCategoryExportUrl(keyword.value)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
ElMessage.success('类目导出已开始下载')
}
onMounted(loadTree)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>商品类目</h2>
<p>维护商品类目树与类目名称/说明有子类目节点不可删除</p>
</div>
<div class="actions">
<el-button type="primary" @click="openCreate(null)">新增根类目</el-button>
<el-button @click="exportCategories">导出</el-button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-row">
<el-input v-model="keyword" placeholder="搜索类目名称(保留父路径)" clearable style="width: 280px" @keyup.enter="apply" />
<el-button type="primary" @click="apply">搜索</el-button>
<el-button @click="reset">清空</el-button>
</div>
</el-card>
<el-card v-if="!searching" shadow="never">
<el-tree
v-loading="loading"
:data="tree"
node-key="id"
:props="{ label: 'name', children: 'children' }"
default-expand-all
highlight-current
@node-click="onNodeClick"
/>
<div class="tree-actions">
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
<span class="btn-group">
<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>
</div>
</el-card>
<el-card v-else shadow="never">
<div class="search-head">
<span>搜索结果 {{ searchTotal.toLocaleString() }} </span>
<span class="dim">{{ keyword }}</span>
</div>
<el-table v-loading="searchLoading" :data="searchRows" stripe border>
<el-table-column prop="name" label="类目名称" min-width="180" />
<el-table-column label="层级路径" min-width="240">
<template #default="{ row }">{{ (row as ProductCategoryNode).path || (row as ProductCategoryNode).name }}</template>
</el-table-column>
<el-table-column label="排序" width="90">
<template #default="{ row }">{{ (row as ProductCategoryNode).sortOrder ?? '—' }}</template>
</el-table-column>
<el-table-column label="说明" min-width="180">
<template #default="{ row }">{{ (row as ProductCategoryNode).description || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as ProductCategoryNode)">编辑</el-button>
<el-button link type="danger" :disabled="!canDelete(row as ProductCategoryNode)" @click="removeCategory(row as ProductCategoryNode)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="searchHasMore" class="load-more-row">
<el-button :loading="searchLoading" @click="loadMore">加载更多搜索结果</el-button>
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form label-width="110px">
<el-form-item label="父级类目">
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable>
<el-option v-for="node in allNodes" :key="node.id" :label="node.path || node.name" :value="node.id" />
</el-select>
</el-form-item>
<el-form-item label="类目名称" required>
<el-input v-model="draft.name" placeholder="类目名称" />
</el-form-item>
<el-form-item label="排序">
<el-input v-model="draft.sortOrder" placeholder="非负整数,可选" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="draft.description" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存类目</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; align-items: center; gap: 10px; }
.filter-row { display: flex; gap: 10px; align-items: center; }
.tree-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.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; }
</style>
@@ -13,3 +13,8 @@ export async function createProductCategory(body: ProductCategorySaveRequest): P
export async function updateProductCategory(id: number, body: ProductCategorySaveRequest): Promise<void> {
await http.put<unknown>(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`, body)
}
/** 删除类目:DELETE /api/admin/product-category/{id}。 */
export async function deleteProductCategory(id: number): Promise<void> {
await http.delete<unknown>(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`)
}
+55
View File
@@ -0,0 +1,55 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// module 13 task 256:商品类目页对齐 admin panel-product-categories —— 导出、删除(有子禁用)、
// 搜索独立分页(加载更多)、新增/编辑弹窗标题切换与「取消编辑」;懒加载走 children 缺口记入清单。
test('test_task_256_category_normal_primary_path', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /deleteProductCategory/, '需接线删除')
assert.match(page, /buildProductCategoryExportUrl/, '需接线导出 URL')
assert.match(page, /确定删除/, '删除需二次确认')
assert.match(page, /childCount|children/, '需按子节点数禁用删除')
})
test('test_task_256_category_normal_variant_input', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /编辑商品类目/, '编辑弹窗标题应对齐 admin')
assert.match(page, /保存类目/, '编辑/新增确认按钮应为「保存类目」')
assert.match(page, /取消编辑/, '编辑态需有「取消编辑」返回入口')
})
test('test_task_256_category_normal_repeated_operation_is_idempotent', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.ok(page.includes('删除'))
assert.ok(page.includes('删除'))
})
test('test_task_256_category_boundary_empty_input', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /新增根类目/, '需保留新增根类目')
assert.match(page, /新增子类目/, '需保留新增子类目')
})
test('test_task_256_category_boundary_single_item', () => {
// 搜索模式需独立分页结果 + 加载更多(对齐 admin search-more)。
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /searchProductCategories|加载更多/, '需搜索分页/加载更多能力')
})
test('test_task_256_category_boundary_limit_or_missing_field', () => {
const api = readSource('src/pages/asin/product-category-editor-api.ts')
assert.match(api, /deleteProductCategory|\.delete</, '编辑 API 需补 DELETE')
})
test('test_task_256_category_invalid_input_rejected', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /导出/, '需有导出入口')
assert.equal(/新增子类目/.test(page) && /selected/.test(page), true)
})
test('test_task_256_category_dependency_failure_returns_actionable_message', () => {
const exp = readSource('src/pages/asin/product-category-export.ts')
assert.match(exp, /buildProductCategoryExportUrl/, '导出模块须提供 URL 构建')
})