|
|
@@ -0,0 +1,1115 @@
|
|
|
+<template>
|
|
|
+ <div class="menus-management">
|
|
|
+ <div class="page-header">
|
|
|
+ <h2>菜单管理</h2>
|
|
|
+ <p>管理系统菜单结构和权限</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 操作栏 -->
|
|
|
+ <div class="toolbar">
|
|
|
+ <div class="toolbar-left">
|
|
|
+ <el-button type="primary" @click="showCreateDialog = true">
|
|
|
+ <el-icon><Plus /></el-icon>
|
|
|
+ 创建菜单
|
|
|
+ </el-button>
|
|
|
+ <el-button @click="expandAll">
|
|
|
+ <el-icon><Expand /></el-icon>
|
|
|
+ 展开全部
|
|
|
+ </el-button>
|
|
|
+ <el-button @click="collapseAll">
|
|
|
+ <el-icon><Fold /></el-icon>
|
|
|
+ 收起全部
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="toolbar-right">
|
|
|
+ <el-input
|
|
|
+ v-model="searchKeyword"
|
|
|
+ placeholder="搜索菜单名称..."
|
|
|
+ style="width: 300px"
|
|
|
+ @input="handleSearch"
|
|
|
+ >
|
|
|
+ <template #prefix>
|
|
|
+ <el-icon><Search /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 菜单树表格 -->
|
|
|
+ <el-table
|
|
|
+ ref="menuTableRef"
|
|
|
+ v-loading="loading"
|
|
|
+ :data="menuTree"
|
|
|
+ style="width: 100%"
|
|
|
+ row-key="id"
|
|
|
+ :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
|
|
+ :default-expand-all="false"
|
|
|
+ :indent="60"
|
|
|
+ border
|
|
|
+ class="menu-tree-table"
|
|
|
+ >
|
|
|
+ <el-table-column prop="title" label="菜单名称" min-width="400">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <div class="menu-info" :class="getMenuLevelClass(row)">
|
|
|
+ <div class="menu-content">
|
|
|
+ <!-- 强化层级指示器 -->
|
|
|
+ <div class="level-indicators">
|
|
|
+ <!-- 主菜单层级指示 -->
|
|
|
+ <div v-if="!row.parent_id" class="level-indicator level-0">
|
|
|
+ <div class="level-bar main-level"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 子菜单层级指示 -->
|
|
|
+ <div v-else-if="getMenuLevel(row) === 1" class="level-indicator level-1">
|
|
|
+ <div class="level-bar sub-level"></div>
|
|
|
+ <div class="level-connector"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 按钮权限层级指示 -->
|
|
|
+ <div v-else-if="getMenuLevel(row) === 2" class="level-indicator level-2">
|
|
|
+ <div class="level-bar button-level"></div>
|
|
|
+ <div class="level-connector"></div>
|
|
|
+ <div class="level-connector-deep"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 菜单图标 -->
|
|
|
+ <el-icon v-if="row.icon" class="menu-icon" :class="getMenuIconClass(row)">
|
|
|
+ <component :is="getIconComponent(row.icon)" />
|
|
|
+ </el-icon>
|
|
|
+ <el-icon v-else class="menu-icon default-icon" :class="getMenuIconClass(row)">
|
|
|
+ <component :is="getDefaultIcon(row)" />
|
|
|
+ </el-icon>
|
|
|
+
|
|
|
+ <!-- 菜单标题 -->
|
|
|
+ <span class="menu-title" :class="getMenuTitleClass(row)">{{ row.title }}</span>
|
|
|
+
|
|
|
+ <!-- 层级标识 -->
|
|
|
+ <span class="level-badge" :class="getLevelBadgeClass(row)">
|
|
|
+ {{ getLevelText(row) }}
|
|
|
+ </span>
|
|
|
+
|
|
|
+ <!-- 菜单类型标签 -->
|
|
|
+ <el-tag
|
|
|
+ v-if="row.menu_type === 'button'"
|
|
|
+ type="warning"
|
|
|
+ size="small"
|
|
|
+ class="menu-type-tag"
|
|
|
+ >
|
|
|
+ 按钮权限
|
|
|
+ </el-tag>
|
|
|
+ <el-tag
|
|
|
+ v-else-if="row.menu_type === 'menu' && row.parent_id"
|
|
|
+ type="success"
|
|
|
+ size="small"
|
|
|
+ class="menu-type-tag"
|
|
|
+ >
|
|
|
+ 功能菜单
|
|
|
+ </el-tag>
|
|
|
+ <el-tag
|
|
|
+ v-else-if="row.menu_type === 'menu' && !row.parent_id"
|
|
|
+ type="primary"
|
|
|
+ size="small"
|
|
|
+ class="menu-type-tag"
|
|
|
+ >
|
|
|
+ 主菜单
|
|
|
+ </el-tag>
|
|
|
+
|
|
|
+ <!-- 隐藏状态 -->
|
|
|
+ <el-tag
|
|
|
+ v-if="row.is_hidden"
|
|
|
+ type="info"
|
|
|
+ size="small"
|
|
|
+ class="menu-type-tag"
|
|
|
+ >
|
|
|
+ 隐藏
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="name" label="菜单标识" width="150" />
|
|
|
+ <el-table-column prop="path" label="路由路径" width="200" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="component" label="组件路径" width="200" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="sort_order" label="排序" width="80" align="center" />
|
|
|
+ <el-table-column prop="is_hidden" label="隐藏" width="80" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag :type="row.is_hidden ? 'danger' : 'success'" size="small">
|
|
|
+ {{ row.is_hidden ? '隐藏' : '显示' }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="is_active" label="状态" width="100" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-switch
|
|
|
+ v-model="row.is_active"
|
|
|
+ @change="handleStatusChange(row)"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="created_at" label="创建时间" width="180">
|
|
|
+ <template #default="{ row }">
|
|
|
+ {{ formatDateTime(row.created_at) }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="200" fixed="right">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button type="primary" size="small" @click="editMenu(row)">
|
|
|
+ 编辑
|
|
|
+ </el-button>
|
|
|
+ <el-button type="success" size="small" @click="addChildMenu(row)">
|
|
|
+ 添加子菜单
|
|
|
+ </el-button>
|
|
|
+ <el-button type="danger" size="small" @click="deleteMenu(row)">
|
|
|
+ 删除
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <!-- 分页 -->
|
|
|
+ <div class="pagination">
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="currentPage"
|
|
|
+ v-model:page-size="pageSize"
|
|
|
+ :page-sizes="[10, 20, 50, 100]"
|
|
|
+ :total="total"
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
+ @size-change="handleSizeChange"
|
|
|
+ @current-change="handleCurrentChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 创建/编辑菜单对话框 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="showCreateDialog"
|
|
|
+ :title="editingMenu ? '编辑菜单' : '创建菜单'"
|
|
|
+ width="700px"
|
|
|
+ @close="resetForm"
|
|
|
+ >
|
|
|
+ <el-form
|
|
|
+ ref="menuFormRef"
|
|
|
+ :model="menuForm"
|
|
|
+ :rules="menuRules"
|
|
|
+ label-width="120px"
|
|
|
+ >
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="菜单名称" prop="title">
|
|
|
+ <el-input v-model="menuForm.title" placeholder="请输入菜单显示名称" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="菜单标识" prop="name">
|
|
|
+ <el-input v-model="menuForm.name" placeholder="请输入菜单标识(英文)" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="父级菜单" prop="parent_id">
|
|
|
+ <el-tree-select
|
|
|
+ v-model="menuForm.parent_id"
|
|
|
+ :data="parentMenuOptions"
|
|
|
+ :props="{ value: 'id', label: 'title', children: 'children' }"
|
|
|
+ placeholder="请选择父级菜单(可选)"
|
|
|
+ clearable
|
|
|
+ check-strictly
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="菜单类型" prop="menu_type">
|
|
|
+ <el-select v-model="menuForm.menu_type" placeholder="请选择菜单类型">
|
|
|
+ <el-option label="菜单" value="menu" />
|
|
|
+ <el-option label="按钮" value="button" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="路由路径" prop="path">
|
|
|
+ <el-input v-model="menuForm.path" placeholder="请输入路由路径" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="组件路径" prop="component">
|
|
|
+ <el-input v-model="menuForm.component" placeholder="请输入组件路径" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="菜单图标" prop="icon">
|
|
|
+ <el-select v-model="menuForm.icon" placeholder="请选择图标" filterable>
|
|
|
+ <el-option
|
|
|
+ v-for="icon in iconOptions"
|
|
|
+ :key="icon.value"
|
|
|
+ :label="icon.label"
|
|
|
+ :value="icon.value"
|
|
|
+ >
|
|
|
+ <div class="icon-option">
|
|
|
+ <el-icon><component :is="getIconComponent(icon.value)" /></el-icon>
|
|
|
+ <span>{{ icon.label }}</span>
|
|
|
+ </div>
|
|
|
+ </el-option>
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="排序顺序" prop="sort_order">
|
|
|
+ <el-input-number v-model="menuForm.sort_order" :min="0" :max="999" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="是否隐藏">
|
|
|
+ <el-switch v-model="menuForm.is_hidden" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="是否启用">
|
|
|
+ <el-switch v-model="menuForm.is_active" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-form-item label="菜单描述" prop="description">
|
|
|
+ <el-input
|
|
|
+ v-model="menuForm.description"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="请输入菜单描述"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="showCreateDialog = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="saveMenu" :loading="saving">
|
|
|
+ {{ editingMenu ? '更新' : '创建' }}
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
+import { Plus, Search, Expand, Fold } from '@element-plus/icons-vue'
|
|
|
+import * as ElementPlusIcons from '@element-plus/icons-vue'
|
|
|
+import request from '@/api/request'
|
|
|
+
|
|
|
+// 响应式数据
|
|
|
+const loading = ref(false)
|
|
|
+const saving = ref(false)
|
|
|
+const menus = ref<any[]>([])
|
|
|
+const searchKeyword = ref('')
|
|
|
+const currentPage = ref(1)
|
|
|
+const pageSize = ref(1000) // 增大页面大小确保加载所有菜单
|
|
|
+const total = ref(0)
|
|
|
+
|
|
|
+// 对话框状态
|
|
|
+const showCreateDialog = ref(false)
|
|
|
+const editingMenu = ref<any>(null)
|
|
|
+const parentMenuId = ref<string | null>(null)
|
|
|
+
|
|
|
+// 表单数据
|
|
|
+const menuForm = reactive({
|
|
|
+ title: '',
|
|
|
+ name: '',
|
|
|
+ parent_id: null as string | null,
|
|
|
+ path: '',
|
|
|
+ component: '',
|
|
|
+ icon: '',
|
|
|
+ sort_order: 0,
|
|
|
+ menu_type: 'menu',
|
|
|
+ is_hidden: false,
|
|
|
+ is_active: true,
|
|
|
+ description: ''
|
|
|
+})
|
|
|
+
|
|
|
+// 表单验证规则
|
|
|
+const menuRules = {
|
|
|
+ title: [
|
|
|
+ { required: true, message: '请输入菜单名称', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ name: [
|
|
|
+ { required: true, message: '请输入菜单标识', trigger: 'blur' },
|
|
|
+ { pattern: /^[a-zA-Z_][a-zA-Z0-9_-]*$/, message: '菜单标识只能包含字母、数字、下划线和连字符,且以字母或下划线开头', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ menu_type: [
|
|
|
+ { required: true, message: '请选择菜单类型', trigger: 'change' }
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+// 图标选项
|
|
|
+const iconOptions = [
|
|
|
+ { label: '首页', value: 'House' },
|
|
|
+ { label: '用户', value: 'User' },
|
|
|
+ { label: '设置', value: 'Setting' },
|
|
|
+ { label: '网格', value: 'Grid' },
|
|
|
+ { label: '监控', value: 'Monitor' },
|
|
|
+ { label: '用户填充', value: 'UserFilled' },
|
|
|
+ { label: '头像', value: 'Avatar' },
|
|
|
+ { label: '菜单', value: 'Menu' },
|
|
|
+ { label: '钥匙', value: 'Key' },
|
|
|
+ { label: '文档', value: 'Document' },
|
|
|
+ { label: '工具', value: 'Tools' },
|
|
|
+ { label: '加号', value: 'Plus' },
|
|
|
+ { label: '编辑', value: 'Edit' },
|
|
|
+ { label: '删除', value: 'Delete' },
|
|
|
+ { label: '搜索', value: 'Search' }
|
|
|
+]
|
|
|
+
|
|
|
+// 引用
|
|
|
+const menuFormRef = ref()
|
|
|
+const menuTableRef = ref()
|
|
|
+
|
|
|
+// 计算属性
|
|
|
+const menuTree = computed(() => {
|
|
|
+ return buildMenuTree(menus.value)
|
|
|
+})
|
|
|
+
|
|
|
+const parentMenuOptions = computed(() => {
|
|
|
+ return buildParentOptions(menus.value)
|
|
|
+})
|
|
|
+
|
|
|
+// 获取图标组件
|
|
|
+const getIconComponent = (iconName: string) => {
|
|
|
+ return (ElementPlusIcons as any)[iconName] || ElementPlusIcons.Menu
|
|
|
+}
|
|
|
+
|
|
|
+// 获取默认图标
|
|
|
+const getDefaultIcon = (menu: any) => {
|
|
|
+ if (menu.menu_type === 'button') {
|
|
|
+ return 'Operation'
|
|
|
+ } else if (menu.parent_id) {
|
|
|
+ return 'Document'
|
|
|
+ } else {
|
|
|
+ return 'Folder'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取菜单层级
|
|
|
+const getMenuLevel = (menu: any) => {
|
|
|
+ if (!menu.parent_id) {
|
|
|
+ return 0 // 主菜单
|
|
|
+ } else {
|
|
|
+ // 查找父菜单是否也有父菜单
|
|
|
+ const parentMenu = menus.value.find(m => m.id === menu.parent_id)
|
|
|
+ if (parentMenu && parentMenu.parent_id) {
|
|
|
+ return 2 // 第三级(按钮权限)
|
|
|
+ } else {
|
|
|
+ return 1 // 第二级(子菜单)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取层级文本
|
|
|
+const getLevelText = (menu: any) => {
|
|
|
+ const level = getMenuLevel(menu)
|
|
|
+ switch (level) {
|
|
|
+ case 0: return 'L1'
|
|
|
+ case 1: return 'L2'
|
|
|
+ case 2: return 'L3'
|
|
|
+ default: return 'L?'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取层级徽章样式类
|
|
|
+const getLevelBadgeClass = (menu: any) => {
|
|
|
+ const level = getMenuLevel(menu)
|
|
|
+ switch (level) {
|
|
|
+ case 0: return 'level-badge-main'
|
|
|
+ case 1: return 'level-badge-sub'
|
|
|
+ case 2: return 'level-badge-button'
|
|
|
+ default: return 'level-badge-default'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取菜单层级样式类
|
|
|
+const getMenuLevelClass = (menu: any) => {
|
|
|
+ const level = getMenuLevel(menu)
|
|
|
+ switch (level) {
|
|
|
+ case 0: return 'menu-level-0' // 主菜单
|
|
|
+ case 1: return 'menu-level-1' // 子菜单
|
|
|
+ case 2: return 'menu-level-2' // 按钮权限
|
|
|
+ default: return 'menu-level-default'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取菜单图标样式类
|
|
|
+const getMenuIconClass = (menu: any) => {
|
|
|
+ if (!menu.parent_id) {
|
|
|
+ return 'main-menu-icon'
|
|
|
+ } else if (menu.menu_type === 'button') {
|
|
|
+ return 'button-icon'
|
|
|
+ } else {
|
|
|
+ return 'sub-menu-icon'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取菜单标题样式类
|
|
|
+const getMenuTitleClass = (menu: any) => {
|
|
|
+ if (!menu.parent_id) {
|
|
|
+ return 'main-menu-title'
|
|
|
+ } else if (menu.menu_type === 'button') {
|
|
|
+ return 'button-title'
|
|
|
+ } else {
|
|
|
+ return 'sub-menu-title'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 构建菜单树
|
|
|
+const buildMenuTree = (menuList: any[]) => {
|
|
|
+ console.log('🌳 构建菜单树,原始数据:', menuList.length, '个菜单')
|
|
|
+
|
|
|
+ if (!menuList || menuList.length === 0) {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建菜单映射
|
|
|
+ const menuMap = new Map()
|
|
|
+ const tree: any[] = []
|
|
|
+
|
|
|
+ // 先创建所有菜单节点
|
|
|
+ menuList.forEach(menu => {
|
|
|
+ menuMap.set(menu.id, {
|
|
|
+ ...menu,
|
|
|
+ children: [],
|
|
|
+ hasChildren: false
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ console.log('📋 菜单映射创建完成,共', menuMap.size, '个节点')
|
|
|
+
|
|
|
+ // 按层级和sort_order排序
|
|
|
+ const sortedMenus = [...menuList].sort((a, b) => {
|
|
|
+ // 先按层级排序(主菜单优先)
|
|
|
+ const aLevel = a.parent_id ? 1 : 0
|
|
|
+ const bLevel = b.parent_id ? 1 : 0
|
|
|
+ if (aLevel !== bLevel) {
|
|
|
+ return aLevel - bLevel
|
|
|
+ }
|
|
|
+ // 同层级按sort_order排序
|
|
|
+ return (a.sort_order || 0) - (b.sort_order || 0)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 构建树结构
|
|
|
+ let rootCount = 0
|
|
|
+ let childCount = 0
|
|
|
+
|
|
|
+ sortedMenus.forEach(menu => {
|
|
|
+ const menuNode = menuMap.get(menu.id)
|
|
|
+ if (menu.parent_id && menuMap.has(menu.parent_id)) {
|
|
|
+ const parentNode = menuMap.get(menu.parent_id)
|
|
|
+ parentNode.children.push(menuNode)
|
|
|
+ parentNode.hasChildren = true // 确保设置hasChildren为true
|
|
|
+ childCount++
|
|
|
+ } else {
|
|
|
+ tree.push(menuNode)
|
|
|
+ rootCount++
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ console.log('🌲 树结构构建完成:', rootCount, '个根节点,', childCount, '个子节点')
|
|
|
+
|
|
|
+ // 对每个节点的children进行排序,并确保hasChildren正确设置
|
|
|
+ const sortChildren = (node: any, level = 0) => {
|
|
|
+ if (node.children && node.children.length > 0) {
|
|
|
+ // 确保hasChildren为true
|
|
|
+ node.hasChildren = true
|
|
|
+
|
|
|
+ // 先按菜单类型排序(menu在前,button在后),再按sort_order排序
|
|
|
+ node.children.sort((a: any, b: any) => {
|
|
|
+ if (a.menu_type !== b.menu_type) {
|
|
|
+ return a.menu_type === 'menu' ? -1 : 1
|
|
|
+ }
|
|
|
+ return (a.sort_order || 0) - (b.sort_order || 0)
|
|
|
+ })
|
|
|
+
|
|
|
+ console.log(`📂 层级 ${level}: ${node.title} 有 ${node.children.length} 个子节点`)
|
|
|
+
|
|
|
+ // 递归排序子节点
|
|
|
+ node.children.forEach(child => sortChildren(child, level + 1))
|
|
|
+ } else {
|
|
|
+ // 确保没有子节点的节点hasChildren为false
|
|
|
+ node.hasChildren = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ tree.forEach(node => sortChildren(node, 0))
|
|
|
+
|
|
|
+ console.log('✅ 菜单树构建完成,返回', tree.length, '个根节点')
|
|
|
+ console.log('🔍 根节点详情:', tree.map(t => ({
|
|
|
+ title: t.title,
|
|
|
+ children: t.children.length,
|
|
|
+ hasChildren: t.hasChildren
|
|
|
+ })))
|
|
|
+
|
|
|
+ return tree
|
|
|
+}
|
|
|
+
|
|
|
+// 构建父级菜单选项
|
|
|
+const buildParentOptions = (menuList: any[]) => {
|
|
|
+ return menuList
|
|
|
+ .filter(menu => menu.menu_type === 'menu')
|
|
|
+ .map(menu => ({
|
|
|
+ id: menu.id,
|
|
|
+ title: menu.title,
|
|
|
+ children: []
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+// 加载菜单列表
|
|
|
+const loadMenus = async () => {
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ const result = await request.get('/api/v1/admin/menus', {
|
|
|
+ params: {
|
|
|
+ page: currentPage.value,
|
|
|
+ page_size: pageSize.value,
|
|
|
+ keyword: searchKeyword.value || undefined
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ if (result.code === 0) {
|
|
|
+ menus.value = result.data.items
|
|
|
+ total.value = result.data.total
|
|
|
+
|
|
|
+ // 数据加载完成后,等待DOM更新,然后展开主要节点
|
|
|
+ await nextTick()
|
|
|
+ setTimeout(() => {
|
|
|
+ expandMainNodes()
|
|
|
+ }, 100)
|
|
|
+ } else {
|
|
|
+ throw new Error(result.message)
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载菜单列表失败:', error)
|
|
|
+ ElMessage.error('加载菜单列表失败')
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 展开主要节点(只展开有子菜单的主菜单)
|
|
|
+const expandMainNodes = () => {
|
|
|
+ if (menuTableRef.value && menuTree.value) {
|
|
|
+ menuTree.value.forEach(node => {
|
|
|
+ if (node.hasChildren && node.children && node.children.length > 0) {
|
|
|
+ // 只展开主菜单,不展开子菜单
|
|
|
+ menuTableRef.value.toggleRowExpansion(node, true)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 搜索处理
|
|
|
+const handleSearch = () => {
|
|
|
+ currentPage.value = 1
|
|
|
+ loadMenus()
|
|
|
+}
|
|
|
+
|
|
|
+// 分页处理
|
|
|
+const handleSizeChange = (size: number) => {
|
|
|
+ pageSize.value = size
|
|
|
+ currentPage.value = 1
|
|
|
+ loadMenus()
|
|
|
+}
|
|
|
+
|
|
|
+const handleCurrentChange = (page: number) => {
|
|
|
+ currentPage.value = page
|
|
|
+ loadMenus()
|
|
|
+}
|
|
|
+
|
|
|
+// 展开/收起
|
|
|
+const expandAll = () => {
|
|
|
+ if (menuTableRef.value) {
|
|
|
+ // 递归展开所有有子节点的节点
|
|
|
+ const expandNode = (nodes: any[]) => {
|
|
|
+ nodes.forEach(node => {
|
|
|
+ if (node.hasChildren && node.children && node.children.length > 0) {
|
|
|
+ menuTableRef.value.toggleRowExpansion(node, true)
|
|
|
+ expandNode(node.children)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ expandNode(menuTree.value)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const collapseAll = () => {
|
|
|
+ if (menuTableRef.value) {
|
|
|
+ // 递归收起所有有子节点的节点
|
|
|
+ const collapseNode = (nodes: any[]) => {
|
|
|
+ nodes.forEach(node => {
|
|
|
+ if (node.hasChildren && node.children && node.children.length > 0) {
|
|
|
+ menuTableRef.value.toggleRowExpansion(node, false)
|
|
|
+ collapseNode(node.children)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ collapseNode(menuTree.value)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 状态切换
|
|
|
+const handleStatusChange = async (menu: any) => {
|
|
|
+ try {
|
|
|
+ await request.put(`/api/v1/admin/menus/${menu.id}`, {
|
|
|
+ is_active: menu.is_active
|
|
|
+ })
|
|
|
+ ElMessage.success('菜单状态更新成功')
|
|
|
+ } catch (error) {
|
|
|
+ console.error('更新菜单状态失败:', error)
|
|
|
+ ElMessage.error('更新菜单状态失败')
|
|
|
+ // 恢复原状态
|
|
|
+ menu.is_active = !menu.is_active
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 编辑菜单
|
|
|
+const editMenu = (menu: any) => {
|
|
|
+ editingMenu.value = menu
|
|
|
+ Object.assign(menuForm, {
|
|
|
+ title: menu.title,
|
|
|
+ name: menu.name,
|
|
|
+ parent_id: menu.parent_id,
|
|
|
+ path: menu.path,
|
|
|
+ component: menu.component,
|
|
|
+ icon: menu.icon,
|
|
|
+ sort_order: menu.sort_order,
|
|
|
+ menu_type: menu.menu_type,
|
|
|
+ is_hidden: menu.is_hidden,
|
|
|
+ is_active: menu.is_active,
|
|
|
+ description: menu.description
|
|
|
+ })
|
|
|
+ showCreateDialog.value = true
|
|
|
+}
|
|
|
+
|
|
|
+// 添加子菜单
|
|
|
+const addChildMenu = (parentMenu: any) => {
|
|
|
+ parentMenuId.value = parentMenu.id
|
|
|
+ menuForm.parent_id = parentMenu.id
|
|
|
+ showCreateDialog.value = true
|
|
|
+}
|
|
|
+
|
|
|
+// 删除菜单
|
|
|
+const deleteMenu = async (menu: any) => {
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ `确定要删除菜单 "${menu.title}" 吗?此操作不可恢复。`,
|
|
|
+ '确认删除',
|
|
|
+ {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ await request.delete(`/api/v1/admin/menus/${menu.id}`)
|
|
|
+ ElMessage.success('菜单删除成功')
|
|
|
+ loadMenus()
|
|
|
+ } catch (error) {
|
|
|
+ // 用户取消删除
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 保存菜单
|
|
|
+const saveMenu = async () => {
|
|
|
+ try {
|
|
|
+ await menuFormRef.value.validate()
|
|
|
+
|
|
|
+ saving.value = true
|
|
|
+
|
|
|
+ if (editingMenu.value) {
|
|
|
+ // 更新菜单
|
|
|
+ await request.put(`/api/v1/admin/menus/${editingMenu.value.id}`, menuForm)
|
|
|
+ ElMessage.success('菜单更新成功')
|
|
|
+ } else {
|
|
|
+ // 创建菜单
|
|
|
+ await request.post('/api/v1/admin/menus', menuForm)
|
|
|
+ ElMessage.success('菜单创建成功')
|
|
|
+ }
|
|
|
+
|
|
|
+ showCreateDialog.value = false
|
|
|
+ loadMenus()
|
|
|
+ } catch (error) {
|
|
|
+ console.error('保存菜单失败:', error)
|
|
|
+ ElMessage.error('保存菜单失败')
|
|
|
+ } finally {
|
|
|
+ saving.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 重置表单
|
|
|
+const resetForm = () => {
|
|
|
+ editingMenu.value = null
|
|
|
+ parentMenuId.value = null
|
|
|
+ Object.assign(menuForm, {
|
|
|
+ title: '',
|
|
|
+ name: '',
|
|
|
+ parent_id: null,
|
|
|
+ path: '',
|
|
|
+ component: '',
|
|
|
+ icon: '',
|
|
|
+ sort_order: 0,
|
|
|
+ menu_type: 'menu',
|
|
|
+ is_hidden: false,
|
|
|
+ is_active: true,
|
|
|
+ description: ''
|
|
|
+ })
|
|
|
+ if (menuFormRef.value) {
|
|
|
+ menuFormRef.value.clearValidate()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化日期时间
|
|
|
+const formatDateTime = (dateTime: string) => {
|
|
|
+ if (!dateTime) return '-'
|
|
|
+ return new Date(dateTime).toLocaleString('zh-CN')
|
|
|
+}
|
|
|
+
|
|
|
+// 组件挂载
|
|
|
+onMounted(() => {
|
|
|
+ loadMenus()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.menus-management {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.page-header {
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.page-header h2 {
|
|
|
+ margin: 0 0 8px 0;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.page-header p {
|
|
|
+ margin: 0;
|
|
|
+ color: #666;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ padding: 16px;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-left {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-right {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+/* 菜单信息容器 */
|
|
|
+.menu-info {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-content {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 32px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 强化层级指示器 */
|
|
|
+.level-indicators {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-right: 8px;
|
|
|
+ min-width: 60px;
|
|
|
+}
|
|
|
+
|
|
|
+.level-indicator {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.level-bar {
|
|
|
+ width: 4px;
|
|
|
+ height: 20px;
|
|
|
+ border-radius: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.main-level {
|
|
|
+ background: linear-gradient(135deg, #409eff, #66b1ff);
|
|
|
+ box-shadow: 0 2px 4px rgba(64, 158, 255, 0.3);
|
|
|
+}
|
|
|
+
|
|
|
+.sub-level {
|
|
|
+ background: linear-gradient(135deg, #67c23a, #85ce61);
|
|
|
+ box-shadow: 0 2px 4px rgba(103, 194, 58, 0.3);
|
|
|
+}
|
|
|
+
|
|
|
+.button-level {
|
|
|
+ background: linear-gradient(135deg, #e6a23c, #ebb563);
|
|
|
+ box-shadow: 0 2px 4px rgba(230, 162, 60, 0.3);
|
|
|
+}
|
|
|
+
|
|
|
+.level-connector {
|
|
|
+ width: 12px;
|
|
|
+ height: 2px;
|
|
|
+ background: #dcdfe6;
|
|
|
+ border-radius: 1px;
|
|
|
+}
|
|
|
+
|
|
|
+.level-connector-deep {
|
|
|
+ width: 8px;
|
|
|
+ height: 2px;
|
|
|
+ background: #f0f0f0;
|
|
|
+ border-radius: 1px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 层级样式类 */
|
|
|
+.menu-level-0 {
|
|
|
+ background: linear-gradient(90deg, rgba(64, 158, 255, 0.05), transparent);
|
|
|
+ border-left: 4px solid #409eff;
|
|
|
+ padding-left: 12px;
|
|
|
+ margin-left: -16px;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-level-1 {
|
|
|
+ background: linear-gradient(90deg, rgba(103, 194, 58, 0.05), transparent);
|
|
|
+ border-left: 4px solid #67c23a;
|
|
|
+ padding-left: 12px;
|
|
|
+ margin-left: -16px;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-level-2 {
|
|
|
+ background: linear-gradient(90deg, rgba(230, 162, 60, 0.05), transparent);
|
|
|
+ border-left: 4px solid #e6a23c;
|
|
|
+ padding-left: 12px;
|
|
|
+ margin-left: -16px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 菜单图标样式 */
|
|
|
+.menu-icon {
|
|
|
+ flex-shrink: 0;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.main-menu-icon {
|
|
|
+ color: #409eff;
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+
|
|
|
+.sub-menu-icon {
|
|
|
+ color: #67c23a;
|
|
|
+ font-size: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.button-icon {
|
|
|
+ color: #e6a23c;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.default-icon {
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+/* 菜单标题样式 */
|
|
|
+.menu-title {
|
|
|
+ font-weight: 500;
|
|
|
+ flex: 1;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.main-menu-title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #303133;
|
|
|
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.sub-menu-title {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.button-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+/* 层级徽章 */
|
|
|
+.level-badge {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 10px;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: bold;
|
|
|
+ text-align: center;
|
|
|
+ min-width: 24px;
|
|
|
+ margin-right: 8px;
|
|
|
+ color: white;
|
|
|
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
|
|
|
+}
|
|
|
+
|
|
|
+.level-badge-main {
|
|
|
+ background: linear-gradient(135deg, #409eff, #66b1ff);
|
|
|
+ border: 1px solid #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.level-badge-sub {
|
|
|
+ background: linear-gradient(135deg, #67c23a, #85ce61);
|
|
|
+ border: 1px solid #67c23a;
|
|
|
+}
|
|
|
+
|
|
|
+.level-badge-button {
|
|
|
+ background: linear-gradient(135deg, #e6a23c, #ebb563);
|
|
|
+ border: 1px solid #e6a23c;
|
|
|
+}
|
|
|
+
|
|
|
+.level-badge-default {
|
|
|
+ background: #909399;
|
|
|
+ border: 1px solid #909399;
|
|
|
+}
|
|
|
+
|
|
|
+/* 菜单类型标签 */
|
|
|
+.menu-type-tag {
|
|
|
+ margin-left: 4px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 11px;
|
|
|
+ padding: 2px 6px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 图标选项 */
|
|
|
+.icon-option {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 分页 */
|
|
|
+.pagination {
|
|
|
+ margin-top: 20px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+/* 表格样式增强 */
|
|
|
+:deep(.el-table) {
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-table__row) {
|
|
|
+ transition: all 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-table__row:hover) {
|
|
|
+ background-color: #f8f9fa !important;
|
|
|
+}
|
|
|
+
|
|
|
+/* 树形表格特殊样式 */
|
|
|
+.menu-tree-table :deep(.el-table__expand-icon) {
|
|
|
+ color: #409eff;
|
|
|
+ font-size: 16px;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-tree-table :deep(.el-table__expand-icon:hover) {
|
|
|
+ color: #66b1ff;
|
|
|
+ transform: scale(1.1);
|
|
|
+}
|
|
|
+
|
|
|
+.menu-tree-table :deep(.el-table__expand-icon.el-table__expand-icon--expanded) {
|
|
|
+ color: #67c23a;
|
|
|
+ transform: rotate(90deg);
|
|
|
+}
|
|
|
+
|
|
|
+/* 表格行层级样式 */
|
|
|
+.menu-tree-table :deep(.el-table__row[data-level="0"]) {
|
|
|
+ background: linear-gradient(90deg, rgba(64, 158, 255, 0.02), transparent);
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-tree-table :deep(.el-table__row[data-level="1"]) {
|
|
|
+ background: linear-gradient(90deg, rgba(103, 194, 58, 0.02), transparent);
|
|
|
+}
|
|
|
+
|
|
|
+.menu-tree-table :deep(.el-table__row[data-level="2"]) {
|
|
|
+ background: linear-gradient(90deg, rgba(230, 162, 60, 0.02), transparent);
|
|
|
+}
|
|
|
+
|
|
|
+/* 对话框样式 */
|
|
|
+:deep(.el-dialog__body) {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 响应式设计 */
|
|
|
+@media (max-width: 768px) {
|
|
|
+ .toolbar {
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toolbar-left,
|
|
|
+ .toolbar-right {
|
|
|
+ width: 100%;
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .level-indicators {
|
|
|
+ min-width: 40px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .menu-content {
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 动画效果 */
|
|
|
+@keyframes levelHighlight {
|
|
|
+ 0% { transform: scale(1); }
|
|
|
+ 50% { transform: scale(1.05); }
|
|
|
+ 100% { transform: scale(1); }
|
|
|
+}
|
|
|
+
|
|
|
+.menu-info:hover .level-bar {
|
|
|
+ animation: levelHighlight 0.6s ease-in-out;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-info:hover .menu-title {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-info:hover .level-badge {
|
|
|
+ transform: scale(1.1);
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
|
+}
|
|
|
+</style>
|