Files
front/src/views/ops/pages/system-settings/menu-management/index.vue
2026-03-08 22:41:42 +08:00

455 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>