ソースを参照

实现菜单项权限控制,过滤用户可见子菜单项,更新路由配置以支持角色权限,优化用户信息获取逻辑。

wuyi 1 ヶ月 前
コミット
927bcee87b

+ 29 - 14
src/components/NavMenuItem.vue

@@ -1,10 +1,7 @@
 <template>
-  <li>
-    <div
-      v-if="item.items"
-      class="nav-menu-item rounded-lg cursor-pointer flex items-center justify-between"
-      @click="toggleSubmenu"
-    >
+  <li v-if="hasPermission">
+    <div v-if="item.items && visibleSubItems.length > 0"
+      class="nav-menu-item rounded-lg cursor-pointer flex items-center justify-between" @click="toggleSubmenu">
       <div class="flex items-center">
         <i :class="item.icon"></i>
         <span class="ml-2">{{ item.label }}</span>
@@ -12,19 +9,15 @@
       <i :class="submenuIcon" class="transition-transform duration-200"></i>
     </div>
 
-    <router-link
-      v-else
-      :to="{ name: item.name }"
-      class="nav-menu-item rounded-lg cursor-pointer flex items-center"
-      :class="{ active: isActive }"
-    >
+    <router-link v-else-if="!item.items" :to="{ name: item.name }"
+      class="nav-menu-item rounded-lg cursor-pointer flex items-center" :class="{ active: isActive }">
       <i :class="item.icon"></i>
       <span class="ml-2">{{ item.label }}</span>
     </router-link>
 
     <transition name="submenu" appear>
-      <ul v-if="item.items && isSubmenuOpen" class="submenu">
-        <NavMenuItem v-for="(subItem, subIndex) in item.items" :key="subIndex" :item="subItem" />
+      <ul v-if="item.items && isSubmenuOpen && visibleSubItems.length > 0" class="submenu">
+        <NavMenuItem v-for="(subItem, subIndex) in visibleSubItems" :key="subIndex" :item="subItem" />
       </ul>
     </transition>
   </li>
@@ -33,8 +26,12 @@
 <script setup>
 import { computed, ref } from 'vue'
 import { useRoute } from 'vue-router'
+import { useUserStore } from '@/stores/user'
+import { hasMenuPermission } from '@/utils/permission'
 
 const route = useRoute()
+const userStore = useUserStore()
+
 const props = defineProps({
   item: {
     type: Object,
@@ -55,6 +52,22 @@ const submenuIcon = computed(() => {
 const toggleSubmenu = () => {
   isSubmenuOpen.value = !isSubmenuOpen.value
 }
+
+// 检查当前用户是否有权限查看此菜单项
+const hasPermission = computed(() => {
+  const userRole = userStore.userInfo?.role
+  return hasMenuPermission(props.item, userRole)
+})
+
+// 过滤子菜单项,只显示当前用户有权限的项
+const visibleSubItems = computed(() => {
+  if (!props.item.items) return []
+
+  const userRole = userStore.userInfo?.role
+  if (!userRole) return []
+
+  return props.item.items.filter(subItem => hasMenuPermission(subItem, userRole))
+})
 </script>
 
 <style scoped>
@@ -65,9 +78,11 @@ const toggleSubmenu = () => {
   transition:
     background-color 0.3s,
     color 0.3s;
+
   &:hover {
     background-color: var(--p-surface-100);
   }
+
   &.active {
     color: var(--p-primary-500);
     font-weight: bold;

+ 23 - 3
src/router/index.js

@@ -3,6 +3,7 @@ import MainView from '@/views/MainView.vue'
 import DashboardView from '@/views/DashboardView.vue'
 import LoginView from '@/views/LoginView.vue'
 import { useUserStore } from '@/stores/user'
+import { hasRoutePermission } from '@/utils/permission'
 
 const router = createRouter({
   history: createWebHistory('/admin/'),
@@ -23,18 +24,27 @@ const router = createRouter({
         {
           path: 'dashboard',
           name: 'dashboard',
-          component: DashboardView
+          component: DashboardView,
+          meta: { roles: ['user', 'admin', 'channel', 'operator', 'mss', 'show'] }
         },
         {
           path: 'user',
           name: 'user',
-          component: () => import('@/views/UserView.vue')
+          component: () => import('@/views/UserView.vue'),
+          meta: { roles: ['admin'] }
         },
         {
           path: 'sys-config',
           name: 'sys-config',
-          component: () => import('@/views/SysConfigView.vue')
+          component: () => import('@/views/SysConfigView.vue'),
+          meta: { roles: ['admin'] }
         },
+        {
+          path: 'sub-menu',
+          name: 'sub-menu',
+          component: () => import('@/views/SubMenuView.vue'),
+          meta: { roles: ['admin'] }
+        }
       ]
     },
     {
@@ -54,9 +64,19 @@ router.beforeEach(async (to, from, next) => {
   const userStore = useUserStore()
   if (to.meta.requiresAuth) {
     if (userStore.token && userStore.userInfo.id) {
+      const userRole = userStore.userInfo.role
+      if (!hasRoutePermission(to, userRole)) {
+        next('/main/dashboard')
+        return
+      }
       next()
     } else if (userStore.token) {
       await userStore.sync()
+      const userRole = userStore.userInfo.role
+      if (!hasRoutePermission(to, userRole)) {
+        next('/main/dashboard')
+        return
+      }
       next()
     } else {
       next('/login')

+ 2 - 1
src/stores/user.js

@@ -19,7 +19,8 @@ export const useUserStore = defineStore('user', () => {
   const login = async (username, password) => {
     const response = await apiLogin(username, password)
     setToken(response.token)
-    setUserInfo(response.user)
+    const userProfile = await profile()
+    setUserInfo(userProfile)
     return response
   }
 

+ 77 - 0
src/utils/permission.js

@@ -0,0 +1,77 @@
+/**
+ * 权限管理工具函数
+ */
+
+/**
+ * 检查用户是否有权限访问菜单项
+ * @param {Object} item - 菜单项对象
+ * @param {string} userRole - 用户角色
+ * @returns {boolean} - 是否有权限
+ */
+export function hasMenuPermission(item, userRole) {
+  if (!userRole) return false
+
+  // 如果菜单项没有定义roles,默认所有角色都可见
+  if (!item.roles || item.roles.length === 0) {
+    return true
+  }
+
+  // 检查当前用户角色是否在允许的角色列表中
+  return item.roles.includes(userRole)
+}
+
+/**
+ * 根据用户角色过滤菜单项(支持多级菜单)
+ * @param {Array} items - 菜单项数组
+ * @param {string} userRole - 用户角色
+ * @returns {Array} - 过滤后的菜单项数组
+ */
+export function filterMenuByRole(items, userRole) {
+  if (!userRole || !items || items.length === 0) return []
+
+  return items
+    .filter((item) => {
+      // 检查当前菜单项权限
+      const hasPermission = hasMenuPermission(item, userRole)
+
+      if (!hasPermission) {
+        return false
+      }
+
+      // 如果有子菜单,递归过滤
+      if (item.items && item.items.length > 0) {
+        const filteredChildren = filterMenuByRole(item.items, userRole)
+
+        // 如果子菜单被全部过滤掉,则当前菜单项也不显示
+        if (filteredChildren.length === 0) {
+          return false
+        }
+
+        // 更新子菜单为过滤后的结果
+        item.items = filteredChildren
+      }
+
+      return true
+    })
+    .map((item) => ({ ...item })) // 返回新对象,避免修改原始数据
+}
+
+/**
+ * 检查用户是否有路由访问权限
+ * @param {Object} route - 路由对象
+ * @param {string} userRole - 用户角色
+ * @returns {boolean} - 是否有权限
+ */
+export function hasRoutePermission(route, userRole) {
+  if (!userRole) return false
+
+  const roles = route.meta?.roles
+
+  // 如果路由没有定义roles,默认所有角色都可访问
+  if (!roles || roles.length === 0) {
+    return true
+  }
+
+  // 检查当前用户角色是否在允许的角色列表中
+  return roles.includes(userRole)
+}

+ 48 - 47
src/views/MainView.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import Button from 'primevue/button'
 import Menu from 'primevue/menu'
@@ -17,33 +17,61 @@ import { useUserStore } from '@/stores/user'
 import { resetPasswordApi } from '@/services/api'
 import { zodResolver } from '@primevue/forms/resolvers/zod'
 import { z } from 'zod'
+import { filterMenuByRole } from '@/utils/permission'
 
 const router = useRouter()
 const sidebarVisible = ref(false)
 const userMenuRef = ref()
 const toast = useToast()
+const userStore = useUserStore()
+
 const showUserMenu = (event) => {
   userMenuRef.value.toggle(event)
 }
 
-const navItems = [
+// 所有菜单项配置,包含角色权限
+const allNavItems = [
   {
     label: '首页',
     icon: 'pi pi-fw pi-home',
-    name: 'dashboard'
+    name: 'dashboard',
+    roles: ['user', 'admin', 'channel', 'operator', 'mss', 'show']
   },
   {
     label: '用户管理',
     icon: 'pi pi-fw pi-user',
-    name: 'user'
+    name: 'user',
+    roles: ['admin']
   },
   {
     label: '参数配置',
     icon: 'pi pi-fw pi-cog',
-    name: 'sys-config'
+    name: 'sys-config',
+    roles: ['admin']
+  },
+  {
+    label: '母菜单示例',
+    icon: 'pi pi-fw pi-list',
+    roles: ['admin'],
+    items: [
+      {
+        label: '子菜单示例',
+        icon: 'pi pi-fw pi-circle',
+        name: 'sub-menu',
+        roles: ['admin']
+      },
+    ]
   }
 ]
 
+// 根据用户角色过滤菜单项
+const navItems = computed(() => {
+  const userRole = userStore.userInfo?.role
+  if (!userRole) return []
+
+  return filterMenuByRole(allNavItems, userRole)
+})
+
 const userMenuItems = [
   {
     label: '修改密码',
@@ -131,23 +159,12 @@ const handleResetPassword = async ({ valid, values }) => {
     </div>
 
     <div class="layout-header fixed top-0 left-0 right-0 w-full z-1 flex items-center">
-      <Button
-        icon="pi pi-bars"
-        severity="secondary"
-        variant="text"
-        @click="sidebarVisible = true"
-        class="mr-4 lg:hidden!"
-      />
+      <Button icon="pi pi-bars" severity="secondary" variant="text" @click="sidebarVisible = true"
+        class="mr-4 lg:hidden!" />
       <img src="@/assets/logo.svg" alt="logo" class="logo" />
       <div class="title flex-1 font-bold ml-4 text-xl">Admin</div>
-      <Button
-        icon="pi pi-user"
-        severity="secondary"
-        @click="showUserMenu"
-        aria-haspopup="true"
-        aria-controls="overlay_menu"
-        class="ml-2"
-      />
+      <Button icon="pi pi-user" severity="secondary" @click="showUserMenu" aria-haspopup="true"
+        aria-controls="overlay_menu" class="ml-2" />
       <Menu ref="userMenuRef" id="overlay_menu" :model="userMenuItems" :popup="true" />
     </div>
 
@@ -156,49 +173,30 @@ const handleResetPassword = async ({ valid, values }) => {
     </Drawer>
 
     <Dialog v-model:visible="resetingPasswordData.visible" modal header="修改密码" :style="{ width: '25rem' }">
-      <Form
-        v-slot="$form"
-        :resolver="resolver"
-        :initialValues="resetingPasswordData"
-        @submit="handleResetPassword"
-        class="p-fluid"
-      >
+      <Form v-slot="$form" :resolver="resolver" :initialValues="resetingPasswordData" @submit="handleResetPassword"
+        class="p-fluid">
         <FloatLabel variant="on" class="mt-2">
           <IconField>
             <InputIcon class="pi pi-lock" />
-            <Password
-              id="password"
-              name="password"
-              v-model="resetingPasswordData.password"
-              fluid
-              toggleMask
-              :feedback="false"
-              autocomplete="off"
-            />
+            <Password id="password" name="password" v-model="resetingPasswordData.password" fluid toggleMask
+              :feedback="false" autocomplete="off" />
           </IconField>
           <label for="password">新密码</label>
         </FloatLabel>
         <Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
           $form.password.error?.message
-        }}</Message>
+          }}</Message>
         <FloatLabel variant="on" class="mt-4">
           <IconField>
             <InputIcon class="pi pi-lock" />
-            <Password
-              id="confirmPassword"
-              name="confirmPassword"
-              v-model="resetingPasswordData.confirmPassword"
-              fluid
-              toggleMask
-              :feedback="false"
-              autocomplete="off"
-            />
+            <Password id="confirmPassword" name="confirmPassword" v-model="resetingPasswordData.confirmPassword" fluid
+              toggleMask :feedback="false" autocomplete="off" />
           </IconField>
           <label for="confirmPassword">重复新密码</label>
         </FloatLabel>
         <Message v-if="$form.confirmPassword?.invalid" severity="error" size="small" variant="simple">{{
           $form.confirmPassword.error?.message
-        }}</Message>
+          }}</Message>
         <div class="field mt-4 text-right">
           <Button label="取消" severity="secondary" @click="resetingPasswordData.visible = false" />
           <Button label="保存" type="submit" class="ml-4" />
@@ -213,6 +211,7 @@ const handleResetPassword = async ({ valid, values }) => {
   height: 4rem;
   background-color: var(--p-surface-0);
   padding: 0 2rem;
+
   .logo {
     width: 1.5rem;
     height: 1.5rem;
@@ -242,9 +241,11 @@ const handleResetPassword = async ({ valid, values }) => {
   .layout-header {
     padding: 0 1rem;
   }
+
   .sidebar {
     display: none;
   }
+
   .main-content {
     margin-left: 0;
     width: 100%;

+ 21 - 0
src/views/SubMenuView.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="sub-menu-1-view">
+    <h1>子菜单</h1>
+    <p>这是子菜单的内容页面</p>
+  </div>
+</template>
+
+<script setup>
+</script>
+
+<style scoped>
+.sub-menu-1-view {
+  padding: 2rem;
+}
+
+h1 {
+  font-size: 2rem;
+  font-weight: bold;
+  margin-bottom: 1rem;
+}
+</style>