455 lines
12 KiB
Vue
455 lines
12 KiB
Vue
<template>
|
||
<div class="container">
|
||
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.menuManagement']" />
|
||
<a-row :gutter="16" class="menu-container">
|
||
<!-- 左侧: 菜单树 -->
|
||
<a-col :xs="24" :md="10" :lg="8">
|
||
<a-card class="menu-tree-card" :bordered="false">
|
||
<!-- 头部操作区 -->
|
||
<template #title>
|
||
<div class="card-header">
|
||
<span>菜单管理</span>
|
||
<a-button type="primary" size="small" @click="handleAddRootMenu">
|
||
<template #icon><icon-plus /></template>
|
||
添加根菜单
|
||
</a-button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 提示信息 -->
|
||
<a-alert class="menu-tip" type="info" :show-icon="true">
|
||
点击菜单项可编辑,悬停显示操作按钮
|
||
</a-alert>
|
||
|
||
<!-- 菜单树 -->
|
||
<a-spin :loading="loading" class="menu-tree-spin">
|
||
<div class="menu-tree-wrapper">
|
||
<MenuTree
|
||
v-if="rootItems.length > 0"
|
||
:nodes="rootItems"
|
||
:expanded-keys="expandedKeys"
|
||
:selected-key="selectedNode?.id || null"
|
||
:dragging-node="draggingNode"
|
||
@toggle="handleToggleExpand"
|
||
@select="handleSelectNode"
|
||
@add-child="handleAddChildMenu"
|
||
@delete="handleDeleteClick"
|
||
@drag-start="handleDragStart"
|
||
@drag-end="handleDragEnd"
|
||
@drop="handleDrop"
|
||
/>
|
||
<a-empty v-else description="暂无菜单数据" />
|
||
</div>
|
||
</a-spin>
|
||
</a-card>
|
||
</a-col>
|
||
|
||
<!-- 右侧: 编辑表单 -->
|
||
<a-col :xs="24" :md="14" :lg="16">
|
||
<a-card class="menu-edit-card" :bordered="false">
|
||
<template #title>
|
||
<div class="edit-header">
|
||
<span>{{ getEditTitle() }}</span>
|
||
<span v-if="isAddingChild || (isEditing && selectedNode?.parent_id)" class="parent-info">
|
||
父菜单:
|
||
<a-tag color="arcoblue">
|
||
{{ isEditing && selectedNode?.parent_id ? getParentMenuName(selectedNode.parent_id) : getParentMenuName(parentId) }}
|
||
</a-tag>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 编辑表单 -->
|
||
<MenuForm
|
||
v-if="isEditing || isAdding || isAddingChild"
|
||
:mode="getEditMode()"
|
||
:parent-id="parentId"
|
||
:initial-values="selectedNode"
|
||
:parent-name="getParentMenuName(parentId)"
|
||
@save="handleSaveMenu"
|
||
@cancel="handleCancelEdit"
|
||
/>
|
||
|
||
<a-empty v-else description="请从左侧选择一个菜单项进行编辑" class="empty-state" />
|
||
</a-card>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<!-- 删除确认对话框 -->
|
||
<a-modal
|
||
v-model:visible="deleteConfirmVisible"
|
||
title="删除确认"
|
||
@ok="handleConfirmDelete"
|
||
@cancel="deleteConfirmVisible = false"
|
||
>
|
||
<p>确定要删除菜单 "{{ nodeToDelete?.title }}" 吗?</p>
|
||
<p v-if="nodeToDelete?.children?.length" class="warning-text">
|
||
<icon-exclamation-circle-fill /> 注意:该菜单下有子菜单,删除后子菜单也会被删除!
|
||
</p>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { Message } from '@arco-design/web-vue'
|
||
import type { MenuNode, MenuRouteRequest } from './types'
|
||
import { fetchMenu, createMenu, modifyMenu, deleteMenu, updateMenuOrder } from '@/api/module/pmn'
|
||
import { buildTree } from '@/utils/tree'
|
||
import MenuTree from './components/MenuTree.vue'
|
||
import MenuForm from './components/MenuForm.vue'
|
||
|
||
// 状态管理
|
||
const loading = ref(false)
|
||
const menuItems = ref<MenuRouteRequest[]>([])
|
||
const expandedKeys = ref<Set<number>>(new Set())
|
||
const selectedNode = ref<MenuNode | null>(null)
|
||
|
||
// 编辑状态
|
||
const isEditing = ref(false)
|
||
const isAdding = ref(false)
|
||
const isAddingChild = ref(false)
|
||
const parentId = ref<number | null>(null)
|
||
|
||
// 删除确认
|
||
const deleteConfirmVisible = ref(false)
|
||
const nodeToDelete = ref<MenuNode | null>(null)
|
||
|
||
// 拖拽状态
|
||
const draggingNode = ref<MenuNode | null>(null)
|
||
|
||
// 计算属性 - 使用公共 buildTree 函数构建树
|
||
const treeData = computed(() => buildTree<MenuNode>(menuItems.value as MenuNode[], { orderKey: 'order' }))
|
||
const rootItems = computed(() => treeData.value.rootItems)
|
||
const itemMap = computed(() => treeData.value.itemMap)
|
||
|
||
// 加载菜单数据
|
||
const loadMenuItems = async () => {
|
||
try {
|
||
loading.value = true
|
||
const res = await fetchMenu({ page: 1, size: 10000 })
|
||
if (res?.code === 0) {
|
||
menuItems.value = res.details?.data || []
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load menu items:', error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 切换节点展开/折叠
|
||
const handleToggleExpand = (nodeId: number) => {
|
||
if (expandedKeys.value.has(nodeId)) {
|
||
expandedKeys.value.delete(nodeId)
|
||
} else {
|
||
expandedKeys.value.add(nodeId)
|
||
}
|
||
}
|
||
|
||
// 选择节点
|
||
const handleSelectNode = (node: MenuNode) => {
|
||
selectedNode.value = node
|
||
isEditing.value = true
|
||
isAdding.value = false
|
||
isAddingChild.value = false
|
||
}
|
||
|
||
// 添加根菜单
|
||
const handleAddRootMenu = () => {
|
||
selectedNode.value = null
|
||
isEditing.value = false
|
||
isAdding.value = true
|
||
isAddingChild.value = false
|
||
parentId.value = null
|
||
}
|
||
|
||
// 添加子菜单
|
||
const handleAddChildMenu = (pId: number) => {
|
||
selectedNode.value = null
|
||
isEditing.value = false
|
||
isAdding.value = false
|
||
isAddingChild.value = true
|
||
parentId.value = pId
|
||
}
|
||
|
||
// 编辑菜单
|
||
const handleEditMenu = (node: MenuNode) => {
|
||
selectedNode.value = node
|
||
isEditing.value = true
|
||
isAdding.value = false
|
||
isAddingChild.value = false
|
||
parentId.value = node.parent_id || null
|
||
}
|
||
|
||
// 删除点击
|
||
const handleDeleteClick = (node: MenuNode) => {
|
||
nodeToDelete.value = node
|
||
deleteConfirmVisible.value = true
|
||
}
|
||
|
||
// 确认删除
|
||
const handleConfirmDelete = async () => {
|
||
if (!nodeToDelete.value?.id) return
|
||
|
||
try {
|
||
loading.value = true
|
||
await deleteMenu({ id: nodeToDelete.value.id })
|
||
Message.success('删除成功')
|
||
|
||
// 如果删除的是当前选中的节点,清空编辑状态
|
||
if (selectedNode.value?.id === nodeToDelete.value.id) {
|
||
selectedNode.value = null
|
||
isEditing.value = false
|
||
isAdding.value = false
|
||
isAddingChild.value = false
|
||
parentId.value = null
|
||
}
|
||
|
||
deleteConfirmVisible.value = false
|
||
nodeToDelete.value = null
|
||
await loadMenuItems()
|
||
} catch (error) {
|
||
console.error('Failed to delete menu:', error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 保存菜单
|
||
const handleSaveMenu = async (menuData: MenuRouteRequest) => {
|
||
try {
|
||
loading.value = true
|
||
if (isEditing.value && selectedNode.value?.id) {
|
||
await modifyMenu({ ...menuData, id: selectedNode.value.id })
|
||
} else {
|
||
await createMenu(menuData)
|
||
}
|
||
Message.success('保存成功')
|
||
|
||
// 重置状态
|
||
isEditing.value = false
|
||
isAdding.value = false
|
||
isAddingChild.value = false
|
||
await loadMenuItems()
|
||
} catch (error) {
|
||
console.error('Failed to save menu:', error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 取消编辑
|
||
const handleCancelEdit = () => {
|
||
isEditing.value = false
|
||
isAdding.value = false
|
||
isAddingChild.value = false
|
||
selectedNode.value = null
|
||
}
|
||
|
||
// 获取编辑模式
|
||
const getEditMode = (): 'add' | 'edit' | 'add_child' => {
|
||
if (isEditing.value) return 'edit'
|
||
if (isAddingChild.value) return 'add_child'
|
||
return 'add'
|
||
}
|
||
|
||
// 获取编辑区标题
|
||
const getEditTitle = () => {
|
||
if (isEditing.value && selectedNode.value) {
|
||
return `编辑: ${selectedNode.value.title}`
|
||
}
|
||
if (isAddingChild.value) {
|
||
const parentName = getParentMenuName(parentId.value)
|
||
return `为 "${parentName}" 添加子菜单`
|
||
}
|
||
if (isAdding.value) return '添加根菜单'
|
||
return '详情'
|
||
}
|
||
|
||
// 获取父菜单名称
|
||
const getParentMenuName = (pId?: number | null) => {
|
||
const map = itemMap.value
|
||
if (!pId || !map.has(pId)) return ''
|
||
const item = map.get(pId)
|
||
return item?.title || ''
|
||
}
|
||
|
||
// 拖拽开始
|
||
const handleDragStart = (node: MenuNode) => {
|
||
draggingNode.value = node
|
||
}
|
||
|
||
// 拖拽结束
|
||
const handleDragEnd = () => {
|
||
draggingNode.value = null
|
||
}
|
||
|
||
// 处理放置
|
||
const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode; position: 'before' | 'after' | 'inside' }) => {
|
||
const { dragNode, targetNode, position } = data
|
||
|
||
if (!dragNode.id) return
|
||
|
||
try {
|
||
loading.value = true
|
||
|
||
if (position === 'inside') {
|
||
// 拖放到某个节点内部,成为其子节点
|
||
const { children, ...dragData } = dragNode
|
||
await modifyMenu({
|
||
...dragData,
|
||
parent_id: targetNode.id
|
||
} as MenuRouteRequest)
|
||
} else {
|
||
// 拖放到某个节点前后,需要更新排序
|
||
const sortList: { pmn_id: number; sort_key: number }[] = []
|
||
let sortKey = 1
|
||
|
||
// 获取同级节点
|
||
const siblings = menuItems.value
|
||
.filter(item => item.parent_id === targetNode.parent_id)
|
||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||
|
||
siblings.forEach((item) => {
|
||
if (item.id === targetNode.id) {
|
||
if (position === 'before') {
|
||
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
|
||
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
|
||
} else {
|
||
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
|
||
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
|
||
}
|
||
} else if (item.id !== dragNode.id) {
|
||
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
|
||
}
|
||
})
|
||
|
||
// 更新拖拽节点的 parent_id
|
||
const { children, ...dragData } = dragNode
|
||
await modifyMenu({
|
||
...dragData,
|
||
parent_id: targetNode.parent_id || null
|
||
} as MenuRouteRequest)
|
||
|
||
// 更新排序
|
||
if (sortList.length > 0) {
|
||
await updateMenuOrder(sortList)
|
||
}
|
||
}
|
||
|
||
Message.success('移动成功')
|
||
await loadMenuItems()
|
||
} catch (error) {
|
||
console.error('Failed to move menu:', error)
|
||
Message.error('移动失败')
|
||
} finally {
|
||
loading.value = false
|
||
draggingNode.value = null
|
||
}
|
||
}
|
||
|
||
// 递归收集子节点
|
||
const collectChildren = (
|
||
items: MenuRouteRequest[],
|
||
parentId: number,
|
||
sortList: { pmn_id: number; sort_key: number }[],
|
||
getSortKey: () => number
|
||
) => {
|
||
const children = items
|
||
.filter((item: MenuRouteRequest) => item.parent_id === parentId)
|
||
.sort((a: MenuRouteRequest, b: MenuRouteRequest) => (a.order || 0) - (b.order || 0))
|
||
|
||
children.forEach((item: MenuRouteRequest) => {
|
||
if (item.id) {
|
||
sortList.push({ pmn_id: item.id, sort_key: getSortKey() })
|
||
collectChildren(items, item.id, sortList, getSortKey)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 初始加载
|
||
onMounted(() => {
|
||
loadMenuItems()
|
||
})
|
||
</script>
|
||
|
||
<script lang="ts">
|
||
export default {
|
||
name: 'MenuManagement',
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.container {
|
||
padding: 0 20px 20px 20px;
|
||
}
|
||
|
||
.menu-container {
|
||
height: calc(100vh - 300px);
|
||
> div {
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.menu-tree-card,
|
||
.menu-edit-card {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
:deep(.arco-card-body) {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.menu-tip {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.menu-tree-spin {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.menu-tree-wrapper {
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.edit-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
|
||
.parent-info {
|
||
font-size: 12px;
|
||
color: var(--color-text-3);
|
||
font-weight: normal;
|
||
}
|
||
}
|
||
|
||
.empty-state {
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.warning-text {
|
||
color: rgb(var(--warning-6));
|
||
margin-top: 8px;
|
||
}
|
||
</style>
|