Просмотр исходного кода

feat(menu): 管理后台菜单管理页面(树形CRUD+角色授权弹窗)与动态侧边栏

asus 1 месяц назад
Родитель
Сommit
da29a5f278

+ 76 - 0
cfc-web/src/api/menu.js

@@ -0,0 +1,76 @@
+import request from '@/utils/request'
+
+/**
+ * 菜单管理 API
+ * 全部使用 POST 方法,路径前缀 /api/admin/menu
+ */
+
+// 获取全量菜单树
+export function getMenuTree() {
+  return request({
+    url: '/api/admin/menu/tree',
+    method: 'post'
+  })
+}
+
+// 获取指定角色的已授权菜单 ID 数组
+export function getRoleMenus(params) {
+  return request({
+    url: '/api/admin/menu/role-menus',
+    method: 'post',
+    data: params // { role }
+  })
+}
+
+// 保存角色授权菜单(覆盖式)
+export function saveRoleMenus(data) {
+  return request({
+    url: '/api/admin/menu/save-role-menus',
+    method: 'post',
+    data // { role, menuIds: [...] }
+  })
+}
+
+// 获取当前用户可见菜单树
+export function getUserMenus() {
+  return request({
+    url: '/api/admin/menu/user-menus',
+    method: 'post'
+  })
+}
+
+// 创建菜单
+export function createMenu(data) {
+  return request({
+    url: '/api/admin/menu/create',
+    method: 'post',
+    data // { parentId, title, label, path, icon, perm, sort, visible }
+  })
+}
+
+// 更新菜单
+export function updateMenu(data) {
+  return request({
+    url: '/api/admin/menu/update',
+    method: 'post',
+    data // 含 id
+  })
+}
+
+// 删除菜单
+export function deleteMenu(data) {
+  return request({
+    url: '/api/admin/menu/delete',
+    method: 'post',
+    data // { id }
+  })
+}
+
+// 调整菜单排序 (up/down)
+export function moveMenu(data) {
+  return request({
+    url: '/api/admin/menu/move',
+    method: 'post',
+    data // { id, direction }
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -167,6 +167,12 @@ const routes = [
         component: () => import('@/views/OperationLogs.vue'),
         meta: { title: '操作日志', perm: 'system:logs' }
       },
+      {
+        path: 'menu-manage',
+        name: 'MenuManage',
+        component: () => import('@/views/admin/MenuManage.vue'),
+        meta: { title: '菜单管理', perm: 'system:menu' }
+      },
       // 服务管理(管理员)
       {
         path: 'service-role-applications',

+ 23 - 1
cfc-web/src/views/Layout.vue

@@ -13,7 +13,7 @@
           active-text-color="#6366F1"
           @select="handleMenuSelect"
         >
-<template v-for="item in menuItems">
+<template v-for="item in effectiveMenuItems">
             <el-submenu v-if="item.children && hasPerm(item.perm)" :key="item.title || item.path" :index="item.title || item.path">
               <template slot="title">
                 <i :class="item.icon"></i>
@@ -157,6 +157,7 @@ import { getAdminInfo, changePassword } from '@/api/auth'
 import { updateAdminInfo } from '@/api/admin'
 import { getMyInviteCode } from '@/api/guide'
 import { getUnreadMessageCount } from '@/api/teacherMessage'
+import { getUserMenus } from '@/api/menu'
 import { getEffectivePermissions, hasPermission, getRoleLabel } from '@/utils/permissions'
 
 export default {
@@ -330,6 +331,8 @@ export default {
               ]},
           ]},
       ],
+      // 后端动态菜单(菜单管理功能;null=未加载/未启用,此时回退到硬编码 menuItems)
+      dynamicMenuItems: null,
       profileDialogVisible: false,
       passwordDialogVisible: false,
       submitting: false,
@@ -379,6 +382,13 @@ export default {
     activeMenu() {
       return this.$route.path
     },
+    // 动态菜单优先,未加载/加载失败时回退硬编码 menuItems
+    effectiveMenuItems() {
+      if (this.dynamicMenuItems && this.dynamicMenuItems.length > 0) {
+        return this.dynamicMenuItems
+      }
+      return this.menuItems
+    },
     currentRole() {
       return localStorage.getItem('role') || 'admin'
     },
@@ -416,6 +426,8 @@ export default {
     // 响应式监听
     this.handleResize = this.handleResize.bind(this)
     window.addEventListener('resize', this.handleResize)
+    // 加载后端动态菜单(失败静默回退到硬编码)
+    this.loadDynamicMenus()
     // 有消息权限(老师/营养师角色)才加载未读数,管理员不调用
     if (hasPermission(this.effectivePerms, 'messages') && this.currentRole !== 'admin') {
       this.fetchUnreadCount()
@@ -428,6 +440,16 @@ export default {
     hasPerm(required) {
       return hasPermission(this.effectivePerms, required)
     },
+    loadDynamicMenus() {
+      const self = this
+      getUserMenus().then(function(res) {
+        if (res && res.code === 200 && res.data && res.data.length > 0) {
+          self.dynamicMenuItems = res.data
+        }
+      }).catch(function() {
+        // 加载失败静默回退硬编码菜单
+      })
+    },
     getMenuIndex(item) {
       return item.path === '/dashboard' && this.currentRole === 'teacher'
         ? '/teacher-dashboard'

+ 340 - 0
cfc-web/src/views/admin/MenuManage.vue

@@ -0,0 +1,340 @@
+<template>
+  <div class="menu-manage admin-page">
+    <el-card>
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">菜单管理</span>
+        <div class="flex items-center gap-sm">
+          <el-button type="primary" size="small" @click="handleRoleAuth">角色授权</el-button>
+          <el-button type="primary" size="small" @click="handleCreate(0)">新增顶级菜单</el-button>
+        </div>
+      </div>
+
+      <div class="table-scroll-wrap">
+        <el-table 
+          :data="menuTree" 
+          row-key="id" 
+          :tree-props="{children: 'children'}" 
+          default-expand-all
+          stripe
+          :max-height="tableHeight"
+        >
+          <el-table-column prop="title" label="菜单名称">
+            <template slot-scope="scope">
+              <i v-if="scope.row.icon" :class="scope.row.icon" style="margin-right: 8px"></i>
+              <span>{{ scope.row.title || scope.row.label }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="icon" label="图标" width="120"></el-table-column>
+          <el-table-column prop="path" label="路由路径" width="180"></el-table-column>
+          <el-table-column prop="perm" label="权限标识" width="150">
+            <template slot-scope="scope">
+              <el-tag size="mini" type="info">{{ scope.row.perm || '-' }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="sort" label="排序" width="80"></el-table-column>
+          <el-table-column prop="visible" label="显示" width="80">
+            <template slot-scope="scope">
+              <el-switch v-model="scope.row.visible" size="mini" @change="handleToggleVisible(scope.row)"></el-switch>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="280" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" @click="handleEdit(row)">编辑</el-button>
+              <el-button size="mini" @click="handleMove(row, 'up')">▲</el-button>
+              <el-button size="mini" @click="handleMove(row, 'down')">▼</el-button>
+              <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+              <el-button size="mini" type="success" @click="handleCreate(row.id)">+ 子菜单</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 创建/编辑弹窗 -->
+    <el-dialog :title="isEdit ? '编辑菜单' : '新增菜单'" :visible.sync="menuDialogVisible" width="550px">
+      <el-form :model="menuForm" :rules="menuRules" ref="menuForm" label-width="100px">
+        <el-form-item label="上级菜单" prop="parentId">
+          <el-select v-model="menuForm.parentId" placeholder="请选择上级菜单" style="width:100%">
+            <el-option :value="0" label="顶级菜单">顶级菜单</el-option>
+            <el-option v-for="item in flatMenuOptions" :key="item.id" :label="item.title" :value="item.id"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="目录名称" prop="title">
+          <el-input v-model="menuForm.title" placeholder="有子菜单时填写"></el-input>
+        </el-form-item>
+        <el-form-item label="菜单标签" prop="label">
+          <el-input v-model="menuForm.label" placeholder="叶子节点显示名"></el-input>
+        </el-form-item>
+        <el-form-item label="路由路径" prop="path">
+          <el-input v-model="menuForm.path" placeholder="/admin/xxx"></el-input>
+        </el-form-item>
+        <el-form-item label="图标" prop="icon">
+          <el-input v-model="menuForm.icon" placeholder="el-icon-s-custom"></el-input>
+        </el-form-item>
+        <el-form-item label="权限标识" prop="perm">
+          <el-input v-model="menuForm.perm" placeholder="system:menu"></el-input>
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="menuForm.sort" :min="0" style="width:100%"></el-input-number>
+        </el-form-item>
+        <el-form-item label="是否显示" prop="visible">
+          <el-switch v-model="menuForm.visible"></el-switch>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="menuDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitMenu">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 角色授权弹窗 -->
+    <el-dialog title="角色菜单授权" :visible.sync="authDialogVisible" width="800px">
+      <div class="auth-container">
+        <div class="role-list">
+          <el-radio-group v-model="selectedRole" class="vertical-radio-group">
+            <el-radio-button v-for="role in roles" :key="role.value" :label="role.value">
+              {{ role.label }}
+            </el-radio-button>
+          </el-radio-group>
+        </div>
+        <div class="menu-auth-tree">
+          <el-tree
+            ref="authTree"
+            :data="menuTree"
+            show-checkbox
+            node-key="id"
+            :props="{ label: 'title' }"
+            :default-checked-keys="checkedMenuIds"
+            @check="handleCheckChange"
+          ></el-tree>
+        </div>
+      </div>
+      <div slot="footer">
+        <el-button @click="authDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitting" @click="saveRoleAuth">保存授权</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { 
+  getMenuTree, getRoleMenus, saveRoleMenus, 
+  createMenu, updateMenu, deleteMenu, moveMenu 
+} from '@/api/menu'
+
+export default {
+  name: 'MenuManage',
+  data() {
+    return {
+      menuTree: [],
+      menuDialogVisible: false,
+      authDialogVisible: false,
+      isEdit: false,
+      submitting: false,
+      
+      // 菜单表单
+      menuForm: this.getEmptyMenuForm(),
+      menuRules: {
+        title: [{ required: false, message: '请输入目录名称', trigger: 'blur' }],
+        label: [{ required: false, message: '请输入菜单标签', trigger: 'blur' }],
+      },
+      
+      // 角色授权
+      selectedRole: 'admin',
+      checkedMenuIds: [],
+      roles: [
+        { label: '管理员', value: 'admin' },
+        { label: '成长规划师', value: 'teacher' },
+        { label: '营养师', value: 'nutritionist' },
+        { label: '文章管理员', value: 'article_manager' },
+        { label: '活动管理员', value: 'activity_manager' },
+        { label: '供应商管理员', value: 'supplier_admin' },
+      ]
+    }
+  },
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 250
+    },
+    flatMenuOptions() {
+      const options = []
+      const flatten = (nodes) => {
+        nodes.forEach(node => {
+          options.push({ id: node.id, title: node.title || node.label })
+          if (node.children) flatten(node.children)
+        })
+      }
+      flatten(this.menuTree)
+      return options
+    }
+  },
+  created() {
+    this.loadMenuTree()
+  },
+  methods: {
+    getEmptyMenuForm() {
+      return {
+        id: null,
+        parentId: 0,
+        title: '',
+        label: '',
+        path: '',
+        icon: '',
+        perm: '',
+        sort: 0,
+        visible: true
+      }
+    },
+    async loadMenuTree() {
+      try {
+        const res = await getMenuTree()
+        if (res.data) this.menuTree = res.data
+      } catch (e) {
+        this.$message.error('加载菜单树失败')
+      }
+    },
+    handleCreate(parentId) {
+      this.isEdit = false
+      this.menuForm = { ...this.getEmptyMenuForm(), parentId }
+      this.menuDialogVisible = true
+    },
+    handleEdit(row) {
+      this.isEdit = true
+      this.menuForm = { ...row }
+      this.menuDialogVisible = true
+    },
+    async submitMenu() {
+      // 简单校验:title 和 label 至少填一个
+      if (!this.menuForm.title && !this.menuForm.label) {
+        this.$message.warning('目录名称或菜单标签必须填写其中一个')
+        return
+      }
+
+      this.submitting = true
+      try {
+        if (this.isEdit) {
+          await updateMenu(this.menuForm)
+        } else {
+          await createMenu(this.menuForm)
+        }
+        this.$message.success('保存成功')
+        this.menuDialogVisible = false
+        this.loadMenuTree()
+      } catch (e) {
+        this.$message.error(e.message || '保存失败')
+      } finally {
+        this.submitting = false
+      }
+    },
+    async handleDelete(row) {
+      try {
+        await this.$confirm('确定删除该菜单及其所有子菜单吗?', '警告', { type: 'warning' })
+        await deleteMenu({ id: row.id })
+        this.$message.success('删除成功')
+        this.loadMenuTree()
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error(e.message || '删除失败')
+      }
+    },
+    async handleMove(row, direction) {
+      try {
+        await moveMenu({ id: row.id, direction })
+        this.$message.success('排序已更新')
+        this.loadMenuTree()
+      } catch (e) {
+        this.$message.error(e.message || '调整失败')
+      }
+    },
+    async handleToggleVisible(row) {
+      try {
+        await updateMenu(row)
+        this.$message.success('状态已更新')
+      } catch (e) {
+        this.$message.error('更新失败')
+      }
+    },
+    async handleRoleAuth() {
+      this.authDialogVisible = true
+      await this.loadRoleMenus()
+    },
+    async loadRoleMenus() {
+      try {
+        const res = await getRoleMenus({ role: this.selectedRole })
+        this.checkedMenuIds = res.data || []
+      } catch (e) {
+        this.$message.error('加载授权菜单失败')
+      }
+    },
+    async saveRoleAuth() {
+      this.submitting = true
+      try {
+        // 合并全选/半选节点
+        const checked = this.$refs.authTree.getCheckedKeys()
+        const halfChecked = this.$refs.authTree.getHalfCheckedKeys()
+        const allIds = [...new Set([...checked, ...halfChecked])]
+        
+        await saveRoleMenus({ 
+          role: this.selectedRole, 
+          menuIds: allIds 
+        })
+        this.$message.success('授权保存成功')
+        this.authDialogVisible = false
+      } catch (e) {
+        this.$message.error(e.message || '保存失败')
+      } finally {
+        this.submitting = false
+      }
+    },
+    handleCheckChange() {
+      // 仅用于触发内部状态更新,实际保存由 saveRoleAuth 处理
+    }
+  },
+  watch: {
+    selectedRole() {
+      this.loadRoleMenus()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.menu-manage {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.auth-container {
+  display: flex;
+  gap: 20px;
+  height: 400px;
+}
+
+.role-list {
+  width: 200px;
+  border-right: 1px solid #ebeef5;
+  padding-right: 20px;
+}
+
+.vertical-radio-group {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.vertical-radio-group .el-radio-button {
+  width: 100%;
+  margin-bottom: 8px;
+}
+
+.menu-auth-tree {
+  flex: 1;
+  overflow-y: auto;
+  padding-left: 10px;
+}
+
+.flex { display: flex; }
+.items-center { align-items: center; }
+.gap-sm { gap: 8px; }
+</style>