feat(F006): 行业管理补齐CRUD+标准化UI - 树形增删改(新增一级/加子级/编辑/启停/递归删除) + 状态胶囊 + 名称搜索 + el-dialog弹窗; 修delete接口调用形态(GET ?id=)

This commit is contained in:
zou 2026-08-19 10:12:09 +08:00
parent 7b84336f82
commit 73d0012f0c

View File

@ -1,55 +1,262 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 行业/资讯分类 - P0 真 view (共用组件, props.type 区分) * F006 行业/资讯分类 - 树形增删改完整版 (2026-08-18 从只读升级为可交付)
* 走 /other/category/categoryDeepPageList (type: 1=行业 3=资讯分类) *
* 2026-08-07 探测: type=1 total=27, type=3 total=4 * 复用 CategoryGoodListView(商品分类) 树形 CRUD 范式 + 企业/用户列表新版 UI 标准
* 字段: id/name/level/pid/sort/status/createDate/children * props.type: 1=行业 3=资讯分类 (保持现状数据源)
*
* 后端端点 (ApiCategoryController /other/category):
* GET categoryDeepPageList?type= 树形列表
* POST save 新增 (body: name/type/level/pid/sort/status)
* POST update 修改 (body, 同 save)
* GET delete?id= 删除 (递归删子级, 注意是 GET query!)
* POST enabled?id= 启用/禁用
*
* UI 标准: PageHeader+新增按钮 / el-table 树形 / 状态圆点胶囊(全局 --st-* 变量) /
* TableActions 操作列 / el-dialog 增改弹窗 / 无边框表格(全局兜底)
*/ */
import { onMounted, ref, reactive } from 'vue' import { onMounted, ref, reactive, computed } from 'vue'
import { httpGet } from '@/utils/request' import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue' import { Plus } from '@element-plus/icons-vue'
import { httpGet, httpPost } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue' import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface Category { id: string; name: string; level: number; pid: string; sort: number; status: number; createDate: string; children?: Category[] } interface Category {
id: string
name: string
level: number
pid: string
sort: number
status: number
createDate: string
children?: Category[]
}
const props = defineProps<{ type?: number; title?: string }>() const props = defineProps<{ type?: number; title?: string }>()
const TITLES: Record<number, string> = { 1: '行业管理', 3: '资讯分类' } const TITLES: Record<number, string> = { 1: '行业管理', 3: '资讯分类' }
const pageTitle = computed(() => props.title || TITLES[props.type ?? 1] || '分类管理')
const catType = computed(() => props.type ?? 1)
const list = ref<Category[]>([]) const LIST = ref<Category[]>([])
const total = ref(0)
const loading = ref(false) const loading = ref(false)
const query = reactive({ page: 1, limit: 100 }) const search = reactive({ name: '' })
const columns = [ // 状态胶囊 (全局 --st-* 变量: 0=正常绿 / 1=停用红)
{ prop: 'id', label: 'ID', width: 80 }, const LEVEL_MAP: Record<number, string> = { 1: '一级', 2: '二级', 3: '三级' }
{ prop: 'name', label: '名称', minWidth: 200 }, function levelLabel(l: number): string { return LEVEL_MAP[l] || `Lv$l` }
{ prop: 'level', label: '层级', width: 80, formatter: (r: Category) => ({ 1: '一级', 2: '二级', 3: '三级', 4: '四级' } as Record<number, string>)[r.level] || String(r.level) },
{ prop: 'sort', label: '排序', width: 80 }, const STATUS_META: Record<number, { label: string; color: string; bg: string }> = {
{ prop: 'status', label: '状态', width: 90, slot: 'status' }, 0: { label: '正常', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
{ prop: 'createDate', label: '创建时间', width: 180 } 1: { label: '停用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
] }
function fmtDate(d: string | null | undefined): string {
if (!d) return '--'
return String(d).slice(0, 19).replace('T', ' ')
}
function statMeta(s: number) { return STATUS_META[s] || STATUS_META[0] }
// 树形过滤 (按名称关键字, 保留命中节点及其父链)
function filterTree(nodes: Category[], kw: string): Category[] {
if (!kw) return nodes
const out: Category[] = []
for (const n of nodes) {
const matched = n.name.includes(kw)
const children = filterTree(n.children || [], kw)
if (matched || children.length) {
out.push({ ...n, children })
}
}
return out
}
const displayList = computed(() => filterTree(LIST.value, search.name.trim()))
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const r: any = await httpGet('/other/category/categoryDeepPageList', { type: props.type ?? 1, page: query.page, limit: query.limit }) const r: any = await httpGet('/other/category/categoryDeepPageList', { type: catType.value, page: 1, limit: 500 })
list.value = r?.list || [] LIST.value = r?.list || []
total.value = Number(r?.total || 0) } catch { LIST.value = [] } finally { loading.value = false }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
} }
function onPageChange(p: number) { query.page = p; load() } function onSearch() { /* 客户端过滤即时生效 */ }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() } function onReset() { search.name = '' }
// ========== 新增/编辑 ==========
const editVisible = ref(false)
const editMode = ref<'add' | 'edit'>('add')
const editForm = reactive({ id: '', name: '', level: 1, pid: '0', sort: 0, status: 0 })
function onAdd(parent?: Category) {
editMode.value = 'add'
const lvl = parent ? Math.min(parent.level + 1, 3) : 1
editForm.id = ''
editForm.pid = parent?.id || '0'
editForm.name = ''
editForm.level = lvl
editForm.sort = 0
editForm.status = 0
editVisible.value = true
}
function onEdit(row: Category) {
editMode.value = 'edit'
editForm.id = row.id
editForm.name = row.name
editForm.level = row.level || 1
editForm.pid = row.pid || '0'
editForm.sort = row.sort ?? 0
editForm.status = row.status ?? 0
editVisible.value = true
}
async function onSubmitEdit() {
if (!editForm.name.trim()) { ElMessage.warning('请输入名称'); return }
const payload = {
id: editForm.id || undefined,
name: editForm.name.trim(),
type: catType.value,
level: editForm.level,
pid: editForm.pid,
sort: editForm.sort,
status: editForm.status
}
try {
await httpPost(editMode.value === 'add' ? '/other/category/save' : '/other/category/update', payload)
ElMessage.success(editMode.value === 'add' ? '新增成功' : '修改成功')
editVisible.value = false
load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
// ========== 启用/禁用 ==========
async function toggleStatus(row: Category) {
const disabling = row.status === 0
try { await ElMessageBox.confirm(`${disabling ? '停用' : '启用'}「${row.name}」?${disabling ? '停用后该分类将不对外展示' : ''}`, '操作确认', { type: 'warning' }) } catch { return }
try {
await httpPost(`/other/category/enabled?id=${row.id}`)
ElMessage.success(disabling ? '已停用' : '已启用')
load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
// ========== 删除 ==========
async function onDelete(row: Category) {
const hasChild = row.children?.length
try {
await ElMessageBox.confirm(
`确认删除「${row.name}」?${hasChild ? `该分类下有 ${row.children!.length} 个子分类, 将一并删除!` : '删除后不可恢复。'}`,
'删除确认', { type: 'warning' }
)
} catch { return }
try {
// 注意: 后端 delete 是 GET 接口 (商品分类页 POST body 是 bug, 此处按实测正确形态)
await httpGet('/other/category/delete', { id: row.id })
ElMessage.success('已删除')
load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
}
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader :title="props.title || TITLES[props.type ?? 1] || '分类管理'" :sub="`共 ${total} 条`" /> <PageHeader :title="pageTitle" :sub="`共 ${LIST.length} 条分类`">
<div class="rr-card"> <el-button type="primary" @click="onAdd()">
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="false" row-key="id" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }" empty-text="暂无数据" /> <el-icon style="margin-right: 4px; vertical-align: -2px"><Plus /></el-icon>
新增一级分类
</el-button>
</PageHeader>
<SearchBar
v-model:filters="search"
:fields="[{ key: 'name', label: '分类名称', type: 'text', placeholder: '按名称搜索' }]"
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card">
<el-table
:data="displayList"
v-loading="loading"
style="width: 100%"
row-key="id"
:tree-props="{ children: 'children' }"
:default-expand-all="false"
:border="false"
empty-text="暂无分类"
>
<el-table-column label="分类名称" min-width="260">
<template #default="{ row }">
<span class="cat-name" :style="{ marginLeft: (row.level - 1) * 24 + 'px', fontWeight: row.level === 1 ? '600' : '400' }">
{{ row.name }}
</span>
</template>
</el-table-column>
<el-table-column label="层级" width="90" align="center">
<template #default="{ row }">{{ levelLabel(row.level) }}</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<span class="st-pill" :style="{ color: statMeta(row.status).color, background: statMeta(row.status).bg }">
<span class="st-dot"></span>{{ statMeta(row.status).label }}
</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ fmtDate(row.createDate) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '添加子级', type: 'primary', when: (r: any) => (r.level || 1) < 3, onClick: onAdd },
{ label: '编辑', type: 'primary', onClick: onEdit },
{ label: (r: any) => r.status === 0 ? '停用' : '启用', type: (r: any) => r.status === 0 ? 'warning' : 'success', onClick: toggleStatus },
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
]"
/>
</template>
</el-table-column>
</el-table>
</div> </div>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增分类' : '修改分类'" width="500px" :close-on-click-modal="false" destroy-on-close>
<el-form label-width="100px" label-position="right">
<el-form-item label="分类名称" required>
<el-input v-model="editForm.name" placeholder="请输入分类名称" maxlength="30" show-word-limit clearable />
</el-form-item>
<el-form-item label="级别" v-if="editMode === 'edit'">
<span class="level-tip">{{ levelLabel(editForm.level) }}</span>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="editForm.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="editForm.status">
<el-radio :value="0">正常</el-radio>
<el-radio :value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmitEdit">确定</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; } .rr-list-card {
</style> background: var(--surface); border-radius: 12px; overflow: hidden;
}
.cat-name { display: inline-block; font-size: 14px; color: var(--ink); }
.level-tip {
display: inline-block; font-size: 13px; color: var(--ink-2);
background: var(--bg, #f6f8fb); border-radius: 6px; padding: 4px 12px;
}
</style>