task-256(admin.html观感对齐): 商品类目页对齐(删除有子禁用/搜索分页加载更多/导出/编辑弹窗语义)
This commit is contained in:
@@ -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> {
|
export async function updateProductCategory(id: number, body: ProductCategorySaveRequest): Promise<void> {
|
||||||
await http.put<unknown>(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`, body)
|
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}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 构建')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user