Forráskód Böngészése

feat(权限管理): 实现按钮级权限控制和菜单管理功能

- 新增按钮权限指令 v-permission 和工具函数 hasPerm,支持权限标识验证
- 扩展菜单类型定义,支持目录(0)/菜单(1)/按钮(2)分类
- 重构权限存储逻辑,分离菜单路由和按钮权限标识
- 实现用户管理、角色管理、菜单管理完整界面
- 优化主题样式生成机制,支持亮色/暗色模式梯度
- 更新模拟数据,包含完整的权限树和按钮权限示例
piks 11 órája
szülő
commit
0067c1b25e

+ 20 - 1
auto-imports.d.ts

@@ -8,6 +8,7 @@ export {}
 declare global {
   const EffectScope: typeof import('vue').EffectScope
   const ElMessage: typeof import('element-plus/es').ElMessage
+  const ElMessageBox: typeof import('element-plus/es').ElMessageBox
   const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
   const computed: typeof import('vue').computed
   const createApp: typeof import('vue').createApp
@@ -86,6 +87,24 @@ declare global {
 // for type re-export
 declare global {
   // @ts-ignore
-  export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
+  export type {
+    Component,
+    Slot,
+    Slots,
+    ComponentPublicInstance,
+    ComputedRef,
+    DirectiveBinding,
+    ExtractDefaultPropTypes,
+    ExtractPropTypes,
+    ExtractPublicPropTypes,
+    InjectionKey,
+    PropType,
+    Ref,
+    ShallowRef,
+    MaybeRef,
+    MaybeRefOrGetter,
+    VNode,
+    WritableComputedRef,
+  } from 'vue'
   import('vue')
 }

+ 2 - 0
components.d.ts

@@ -29,6 +29,7 @@ declare module 'vue' {
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElInput: typeof import('element-plus/es')['ElInput']
+    ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElOption: typeof import('element-plus/es')['ElOption']
@@ -45,6 +46,7 @@ declare module 'vue' {
     ElTable: typeof import('element-plus/es')['ElTable']
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElTree: typeof import('element-plus/es')['ElTree']
     Pagination: typeof import('./src/components/Pagination/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 33 - 0
src/directives/permission.ts

@@ -0,0 +1,33 @@
+import type { Directive } from 'vue'
+import { usePermissionStore } from '@/store/modules/permission'
+
+/**
+ * 按钮权限指令
+ * 用法:v-permission="'sys:user:add'" 或 v-permission="['sys:user:add', 'sys:user:edit']"
+ * 无权限时直接移除 DOM 元素
+ */
+export const vPermission: Directive<HTMLElement, string | string[]> = {
+  mounted(el, binding) {
+    const permissionStore = usePermissionStore()
+    const value = binding.value
+
+    if (!value) return
+
+    const required = Array.isArray(value) ? value : [value]
+    const hasAuth = required.some((p) => permissionStore.permissions.includes(p))
+
+    if (!hasAuth) {
+      el.parentNode?.removeChild(el)
+    }
+  },
+}
+
+/**
+ * 函数式权限判断,用于 JS/TS 逻辑中
+ * @param perm - 权限标识,如 'sys:user:add'
+ * @returns 是否有该权限
+ */
+export function hasPerm(perm: string): boolean {
+  const permissionStore = usePermissionStore()
+  return permissionStore.permissions.includes(perm)
+}

+ 6 - 0
src/main.ts

@@ -16,6 +16,9 @@ import StatusText from '@/components/StatusText/index.vue'
 import SvgIcon from '@/components/SvgIcon/index.vue'
 import 'virtual:svg-icons-register'
 
+// 按钮权限指令
+import { vPermission } from '@/directives/permission'
+
 // ⚠️ 路由守卫必须在 createApp 后、mount 前引入
 import './permission'
 
@@ -34,5 +37,8 @@ app.component('Pagination', Pagination)
 // 注册状态文本组件
 app.component('StatusText', StatusText)
 
+// 注册按钮权限指令
+app.directive('permission', vPermission)
+
 app.use(Pinia).use(router)
 app.mount('#app')

+ 161 - 22
src/mock/index.ts

@@ -3,79 +3,218 @@ import type { MenuItem, LoginResponse, UserInfoResponse, RoutesResponse } from '
 // ────────── 模拟用户数据库 ──────────
 const mockUsers = {
   admin: { token: 'mock-token-admin', roles: ['admin'], name: '超级管理员', avatar: '' },
-  editor: { token: 'mock-token-editor', roles: ['editor'], name: '内容编辑', avatar: '' }
+  editor: { token: 'mock-token-editor', roles: ['editor'], name: '内容编辑', avatar: '' },
 }
 
 // Token 反查用户
 const tokenMap: Record<string, typeof mockUsers.admin> = {
   'mock-token-admin': mockUsers.admin,
-  'mock-token-editor': mockUsers.editor
+  'mock-token-editor': mockUsers.editor,
 }
 
 // ────────── 模拟后端存储的全量路由 ──────────
 // 真实场景中这份数据存在数据库,后端根据角色过滤后返回
 const allAsyncRoutes: MenuItem[] = [
   {
+    id: 1,
+    parentId: 0,
+    type: 1,
+    sort: 0,
     path: '/dashboard',
     name: 'Dashboard',
     component: 'dashboard/index',
-    meta: { title: '首页', icon: 'HomeFilled', roles: ['admin', 'editor'] }
+    meta: { title: '首页', icon: 'HomeFilled', roles: ['admin', 'editor'] },
   },
   {
-    path: "/sdk",
-    name: "Sdk",
-    component: "sdk/index",
-    meta: { title: "SDK管理", icon: "Box", roles: ["admin"] },
+    id: 200,
+    parentId: 0,
+    type: 1,
+    sort: 5,
+    path: '/sdk',
+    name: 'Sdk',
+    component: 'sdk/index',
+    meta: { title: 'SDK管理', icon: 'Box', roles: ['admin'] },
     children: [
       {
-        path: "detail",
-        name: "SdkDetail",
+        id: 201,
+        parentId: 200,
+        type: 1,
+        sort: 1,
+        path: 'detail',
+        name: 'SdkDetail',
         component: 'sdk/detail',
-        meta: { title: "管理详情", icon: "Box", roles: ["admin"], hidden: true },
-      }
-    ]
+        meta: { title: '管理详情', icon: 'Box', roles: ['admin'], hidden: true },
+      },
+    ],
   },
   {
+    id: 100,
+    parentId: 0,
+    type: 0,
+    sort: 10,
     path: '/system',
     name: 'System',
     redirect: '/system/user',
     meta: { title: '系统管理', icon: 'Setting', roles: ['admin'] },
     children: [
       {
+        id: 101,
+        parentId: 100,
+        type: 1,
+        sort: 1,
         path: 'user',
         name: 'SystemUser',
         component: 'system/user/index',
-        meta: { title: '用户管理', icon: 'User', roles: ['admin'] }
+        meta: { title: '用户管理', icon: 'User', roles: ['admin'] },
+        children: [
+          {
+            id: 1011,
+            parentId: 101,
+            type: 2,
+            sort: 1,
+            path: '',
+            permission: 'sys:user:add',
+            meta: { title: '新增用户' },
+          },
+          {
+            id: 1012,
+            parentId: 101,
+            type: 2,
+            sort: 2,
+            path: '',
+            permission: 'sys:user:edit',
+            meta: { title: '编辑用户' },
+          },
+          {
+            id: 1013,
+            parentId: 101,
+            type: 2,
+            sort: 3,
+            path: '',
+            permission: 'sys:user:delete',
+            meta: { title: '删除用户' },
+          },
+        ],
       },
       {
+        id: 102,
+        parentId: 100,
+        type: 1,
+        sort: 2,
         path: 'role',
         name: 'SystemRole',
         component: 'system/role/index',
-        meta: { title: '角色管理', icon: 'UserFilled', roles: ['admin'] }
-      }
-    ]
+        meta: { title: '角色管理', icon: 'UserFilled', roles: ['admin'] },
+        children: [
+          {
+            id: 1021,
+            parentId: 102,
+            type: 2,
+            sort: 1,
+            path: '',
+            permission: 'sys:role:add',
+            meta: { title: '新增角色' },
+          },
+          {
+            id: 1022,
+            parentId: 102,
+            type: 2,
+            sort: 2,
+            path: '',
+            permission: 'sys:role:edit',
+            meta: { title: '编辑角色' },
+          },
+          {
+            id: 1023,
+            parentId: 102,
+            type: 2,
+            sort: 3,
+            path: '',
+            permission: 'sys:role:delete',
+            meta: { title: '删除角色' },
+          },
+          {
+            id: 1024,
+            parentId: 102,
+            type: 2,
+            sort: 4,
+            path: '',
+            permission: 'sys:role:assign',
+            meta: { title: '分配权限' },
+          },
+        ],
+      },
+      {
+        id: 103,
+        parentId: 100,
+        type: 1,
+        sort: 3,
+        path: 'menu',
+        name: 'SystemMenu',
+        component: 'system/menu/index',
+        meta: { title: '菜单管理', icon: 'Menu', roles: ['admin'] },
+        children: [
+          {
+            id: 1031,
+            parentId: 103,
+            type: 2,
+            sort: 1,
+            path: '',
+            permission: 'sys:menu:add',
+            meta: { title: '新增菜单' },
+          },
+          {
+            id: 1032,
+            parentId: 103,
+            type: 2,
+            sort: 2,
+            path: '',
+            permission: 'sys:menu:edit',
+            meta: { title: '编辑菜单' },
+          },
+          {
+            id: 1033,
+            parentId: 103,
+            type: 2,
+            sort: 3,
+            path: '',
+            permission: 'sys:menu:delete',
+            meta: { title: '删除菜单' },
+          },
+        ],
+      },
+    ],
   },
   {
+    id: 300,
+    parentId: 0,
+    type: 0,
+    sort: 20,
     path: '/content',
     name: 'Content',
     redirect: '/content/article',
     meta: { title: '内容管理', icon: 'Document', roles: ['admin', 'editor'] },
     children: [
       {
+        id: 301,
+        parentId: 300,
+        type: 1,
+        sort: 1,
         path: 'article',
         name: 'Article',
         component: 'content/article/index',
-        meta: { title: '文章管理', icon: 'Document', roles: ['admin', 'editor'] }
-      }
-    ]
-  }
+        meta: { title: '文章管理', icon: 'Document', roles: ['admin', 'editor'] },
+      },
+    ],
+  },
 ]
 
 // ────────── 工具函数 ──────────
 function filterRoutesByRoles(routes: MenuItem[], roles: string[]): MenuItem[] {
   return routes.reduce<MenuItem[]>((acc, route) => {
+    // type=2(按钮)没有 meta.roles,继承父节点权限,直接保留
     const allowed = route.meta?.roles
-    const hasPermission = !allowed || roles.some(r => allowed.includes(r))
+    const hasPermission = route.type === 2 || !allowed || roles.some((r) => allowed.includes(r))
     if (hasPermission) {
       const item = { ...route }
       if (item.children?.length) {
@@ -87,7 +226,7 @@ function filterRoutesByRoles(routes: MenuItem[], roles: string[]): MenuItem[] {
   }, [])
 }
 
-const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
+const delay = (ms: number) => new Promise((r) => setTimeout(r, ms))
 
 // ────────── Mock 接口 ──────────
 /** 模拟登录 */

+ 5 - 5
src/router/index.ts

@@ -11,7 +11,7 @@ export const constantRoutes: RouteRecordRaw[] = [
     path: '/auth',
     name: 'auth',
     component: () => import('@/views/auth/index.vue'),
-    meta: { hidden: true, title: '登录' }
+    meta: { hidden: true, title: '登录' },
   },
   {
     // Root Layout:所有需要登录的页面都是它的子路由
@@ -19,8 +19,8 @@ export const constantRoutes: RouteRecordRaw[] = [
     name: 'Root', // ← 重要:addRoute 时通过 name 定位父路由
     component: Layout,
     redirect: '/dashboard',
-    children: [] // 动态路由在此注入
-  }
+    children: [], // 动态路由在此注入
+  },
   // 注意:404 路由移到动态添加,避免先于权限路由匹配
 ]
 
@@ -31,13 +31,13 @@ export const notFoundRoute: RouteRecordRaw = {
   path: '/:pathMatch(.*)*',
   name: 'NotFound',
   component: () => import('@/views/error/404.vue'),
-  meta: { hidden: true, title: '404' }
+  meta: { hidden: true, title: '404' },
 }
 
 const router = createRouter({
   history: createWebHistory(),
   routes: constantRoutes,
-  scrollBehavior: () => ({ top: 0 })
+  scrollBehavior: () => ({ top: 0 }),
 })
 
 export default router

+ 39 - 7
src/store/modules/permission.ts

@@ -6,26 +6,57 @@ import { getRoutes as apiGetRoutes } from '@/api'
 import { flattenMenuToRoutes } from '@/utils/routeHelper'
 
 export const usePermissionStore = defineStore('permission', () => {
-  /** 用于 Sidebar 渲染的嵌套菜单数据(保持原始层级结构) */
+  /** 用于 Sidebar 渲染的嵌套菜单数据(保持原始层级结构,不含按钮) */
   const menuList = ref<MenuItem[]>([])
 
+  /** 当前用户的按钮权限标识列表,如 ['sys:user:add', 'sys:user:edit'] */
+  const permissions = ref<string[]>([])
+
   /** 标记:动态路由是否已加载完毕 */
   const isRoutesLoaded = ref(false)
 
+  /** 从菜单树中递归收集所有 type=2 的 permission */
+  function extractPermissions(menus: MenuItem[]): string[] {
+    const perms: string[] = []
+    for (const item of menus) {
+      if (item.type === 2 && item.permission) {
+        perms.push(item.permission)
+      }
+      if (item.children?.length) {
+        perms.push(...extractPermissions(item.children))
+      }
+    }
+    return perms
+  }
+
+  /** 过滤掉按钮节点后的菜单树,给 Sidebar 用 */
+  function filterButtonsFromMenu(menus: MenuItem[]): MenuItem[] {
+    return menus
+      .filter((item) => item.type !== 2)
+      .map((item) => ({
+        ...item,
+        children: item.children ? filterButtonsFromMenu(item.children) : [],
+      }))
+  }
+
   /**
    * 核心函数:
    * 1. 从 Mock/后端拉取当前用户有权限的路由
-   * 2. 更新 menuList(供 Sidebar 使用)
-   * 3. 返回扁平化的路由配置(由路由守卫负责注册到 router)
+   * 2. 提取按钮权限标识
+   * 3. 更新 menuList(供 Sidebar 使用,不含按钮)
+   * 4. 返回扁平化的路由配置(由路由守卫负责注册到 router)
    */
   async function generateRoutes(token: string): Promise<RouteRecordRaw[]> {
     const res = await apiGetRoutes(token)
     const rawMenuData: MenuItem[] = res.data
 
-    // 保留嵌套结构给 Sidebar 使用
-    menuList.value = rawMenuData
+    // ① 提取按钮权限
+    permissions.value = extractPermissions(rawMenuData)
+
+    // ② 过滤按钮后存给 Sidebar
+    menuList.value = filterButtonsFromMenu(rawMenuData)
 
-    // 转换为扁平路由,返回给调用方(路由守卫)注册
+    // ③ 生成路由
     const flatRoutes = flattenMenuToRoutes(rawMenuData)
 
     isRoutesLoaded.value = true
@@ -35,8 +66,9 @@ export const usePermissionStore = defineStore('permission', () => {
   /** 登出时重置(配合页面刷新使用) */
   function reset() {
     menuList.value = []
+    permissions.value = []
     isRoutesLoaded.value = false
   }
 
-  return { menuList, isRoutesLoaded, generateRoutes, reset }
+  return { menuList, permissions, isRoutesLoaded, generateRoutes, reset }
 })

+ 30 - 65
src/styles/theme.scss

@@ -1,60 +1,40 @@
-// ========== 导入 Sass 模块 ==========
 @use 'sass:color';
 
 // =============================================
-//  颜色梯度生成 Mixin
+//  安全梯度生成 Mixin(仅生成主色梯度,不干涉其它设定)
 // =============================================
-
-/**
- * 生成 Element Plus 语义色的全部梯度变量
- * @param {String} $name     - 色名,如 'primary', 'success'
- * @param {Color}  $base     - 基色
- * @param {Color}  $mix-light - light 梯度混合色
- * @param {Color}  $mix-dark  - dark-2 混合色
- */
-@mixin set-el-color($name, $base, $mix-light, $mix-dark) {
+@mixin generate-theme-colors($name, $base, $mode: 'light') {
   --el-color-#{$name}: #{$base};
 
-  @for $i from 1 through 9 {
-    --el-color-#{$name}-light-#{$i}: color.mix($mix-light, $base, $i * 10%);
-  }
-
-  --el-color-#{$name}-dark-2: color.mix($mix-dark, $base, 20%);
-}
-
-/**
- * 批量生成多个语义色的梯度变量
- */
-@mixin set-el-colors($color-map, $mix-light, $mix-dark) {
-  @each $name, $base in $color-map {
-    @include set-el-color($name, $base, $mix-light, $mix-dark);
+  @if $mode == 'light' {
+    // 亮色模式:light 混合纯白,dark 混合纯黑
+    @for $i from 1 through 9 {
+      --el-color-#{$name}-light-#{$i}: #{color.mix(#ffffff, $base, $i * 10%)};
+    }
+    --el-color-#{$name}-dark-2: #{color.mix(#000000, $base, 20%)};
+  } @else if $mode == 'dark' {
+    // 暗色模式:light 混合 #141414,dark 混合纯白(按下反馈变亮)
+    $dark-bg: #141414;
+    @for $i from 1 through 9 {
+      --el-color-#{$name}-light-#{$i}: #{color.mix($dark-bg, $base, $i * 10%)};
+    }
+    --el-color-#{$name}-dark-2: #{color.mix(#ffffff, $base, 20%)};
   }
 }
 
-// =============================================
-//  亮色模式(默认)
-// =============================================
+// ========== 亮色模式(默认) ==========
 :root {
-  // ---- Element Plus 色系 ----
-  @include set-el-colors(
-    (
-      'primary': #8077ff,
-      'success': #10b981,
-      'warning': #e6a23c,
-      'danger': #f56c6c,
-      'info': #909399,
-    ),
-    #ffffff,
-    // light 梯度 → 与白色混合
-    #000000 // dark-2    → 与黑色混合
-  );
+  // 1. 自动生成 Primary 亮色梯度
+  @include generate-theme-colors('primary', #8077ff, 'light');
+  // 2. 自动生成 Success 亮色梯度
+  @include generate-theme-colors('success', #10b981, 'light');
 
-  // ---- 渐变按钮 ----
+  // 自定义渐变按钮变量
   --btn-primary-gradient: linear-gradient(91deg, #5f53ff -40.99%, #8d53ff 88.34%);
   --btn-primary-gradient-hover: linear-gradient(95deg, #8d22ff -9.99%, #ae00ff 91.44%);
   --btn-primary-shadow: rgba(102, 126, 234, 0.4);
 
-  // ---- 页面级变量 ----
+  // 页面级自定义变量
   --admin-bg: #f0f2f5;
   --admin-sidebar-bg: #ffffff;
   --admin-header-bg: #ffffff;
@@ -66,32 +46,19 @@
   --admin-table-header-bg: #f5f7fa;
 }
 
-// =============================================
-//  暗色模式
-// =============================================
+// ========== 暗色模式 ==========
 html.dark {
-  $dark-bg: #141414;
+  // 1. 自动生成 Primary 暗色梯度
+  @include generate-theme-colors('primary', #8077ff, 'dark');
+  // 2. 自动生成 Success 暗色梯度
+  @include generate-theme-colors('success', #35ffdd, 'dark');
 
-  // ---- Element Plus 色系 ----
-  @include set-el-colors(
-    (
-      'primary': #8077ff,
-      'success': #35ffdd,
-      'warning': #e6a23c,
-      'danger': #f56c6c,
-      'info': #909399,
-    ),
-    $dark-bg,
-    // light 梯度 → 与暗色背景混合
-    #ffffff // dark-2    → 与白色混合(按下变亮)
-  );
-
-  // ---- 渐变按钮 ----
+  // 暗色下的渐变按钮
   --btn-primary-gradient: linear-gradient(135deg, #5a6fe0 0%, #6a3f9a 100%);
   --btn-primary-gradient-hover: linear-gradient(135deg, #6a3f9a 0%, #5a6fe0 100%);
   --btn-primary-shadow: rgba(90, 111, 224, 0.3);
 
-  // ---- 页面级变量 ----
+  // 页面级自定义变量
   --admin-bg: #050505;
   --admin-sidebar-bg: #050505;
   --admin-header-bg: #050505;
@@ -104,9 +71,7 @@ html.dark {
   --admin-btn-bg: #2f2f2f;
 }
 
-// =============================================
-//  主题切换过渡
-// =============================================
+// 全局平滑过渡(主题切换时)
 html.theme-transition,
 html.theme-transition *,
 html.theme-transition *::before,

+ 11 - 0
src/types/menu.ts

@@ -1,3 +1,8 @@
+/**
+ * 菜单类型:0=目录  1=菜单  2=按钮
+ */
+export type MenuType = 0 | 1 | 2
+
 /**
  * 路由元信息类型定义
  */
@@ -14,6 +19,12 @@ export interface RouteMeta {
  * 菜单项类型定义
  */
 export interface MenuItem {
+  id: number // 菜单唯一标识,用于角色分配勾选
+  parentId: number // 父节点 ID,构建树形结构
+  type: MenuType // 区分目录(0)/菜单(1)/按钮(2)
+  permission?: string // 按钮权限标识,如 'sys:user:add'
+  sort?: number // 排序权重
+
   path: string
   name?: string
   component?: string // 对应 views 下的文件路径(字符串)

+ 12 - 12
src/utils/routeHelper.ts

@@ -50,18 +50,18 @@ function isHidden(item: MenuItem): boolean {
  * 3. 父路由如果有 visible 子路由,则可配置 redirect
  * 4. 路由默认都挂载在根下(适合 Layout + router-view)
  */
-export function flattenMenuToRoutes(
-  menuItems: MenuItem[],
-  parentPath = ''
-): RouteRecordRaw[] {
+export function flattenMenuToRoutes(menuItems: MenuItem[], parentPath = ''): RouteRecordRaw[] {
   const routes: RouteRecordRaw[] = []
 
   for (const item of menuItems) {
+    // ★ 按钮不生成路由,直接跳过
+    if (item.type === 2) continue
+
     const fullPath = resolvePath(parentPath, item.path)
-    const children = item.children || []
+    const children = (item.children || []).filter((c) => c.type !== 2) // 排除按钮子项
 
-    const visibleChildren = children.filter(child => !isHidden(child))
-    const hiddenChildren = children.filter(child => isHidden(child))
+    const visibleChildren = children.filter((child) => !isHidden(child))
+    const hiddenChildren = children.filter((child) => isHidden(child))
 
     // 1) 当前菜单本身生成路由
     // 如果有 component,说明它是一个可访问页面
@@ -70,7 +70,7 @@ export function flattenMenuToRoutes(
         path: fullPath,
         name: item.name,
         component: loadComponent(item.component),
-        meta: item.meta
+        meta: item.meta,
       } as RouteRecordRaw)
     }
 
@@ -83,7 +83,7 @@ export function flattenMenuToRoutes(
         routes.push({
           path: fullPath,
           redirect: item.redirect || resolvePath(fullPath, visibleChildren[0].path),
-          meta: item.meta
+          meta: item.meta,
         } as RouteRecordRaw)
       }
 
@@ -102,11 +102,11 @@ export function flattenMenuToRoutes(
         meta: {
           ...child.meta,
           parentPath: fullPath,
-          parentTitle: item.meta?.title
-        }
+          parentTitle: item.meta?.title,
+        },
       } as RouteRecordRaw)
     }
   }
 
   return routes
-}
+}

+ 620 - 0
src/views/system/menu/index.vue

@@ -0,0 +1,620 @@
+<template>
+  <div class="menu-management">
+    <Breadcrumb />
+
+    <el-card class="table-card" shadow="never">
+      <div class="table-toolbar">
+        <div class="toolbar-left">
+          <el-button
+            v-permission="'sys:menu:add'"
+            type="primary"
+            :icon="CirclePlus"
+            size="large"
+            class="is-solid"
+            @click="handleAdd()"
+            >新增菜单</el-button
+          >
+          <el-button type="primary" :icon="Sort" size="large" class="is-solid" @click="toggleExpand"
+            >展开/折叠</el-button
+          >
+        </div>
+        <div class="toolbar-right">
+          <el-input
+            v-model="searchKeyword"
+            :prefix-icon="Search"
+            size="large"
+            placeholder="菜单名称"
+            style="width: 240px"
+            clearable
+          />
+          <el-button
+            :icon="Search"
+            type="primary"
+            class="is-solid"
+            size="large"
+            @click="handleSearch"
+            >搜索</el-button
+          >
+          <el-button
+            :icon="Refresh"
+            type="primary"
+            class="is-solid"
+            size="large"
+            @click="handleReset"
+            >重置</el-button
+          >
+        </div>
+      </div>
+
+      <el-table
+        ref="tableRef"
+        :data="filteredMenuData"
+        row-key="id"
+        :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
+        border
+        :default-expand-all="isExpandAll"
+      >
+        <el-table-column prop="name" label="菜单名称" min-width="180" />
+        <el-table-column prop="type" label="类型" width="80" align="center">
+          <template #default="{ row }">
+            <el-tag v-if="row.type === 0" type="warning">目录</el-tag>
+            <el-tag v-else-if="row.type === 1">菜单</el-tag>
+            <el-tag v-else-if="row.type === 2" type="info">按钮</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="icon" label="图标" width="80" align="center">
+          <template #default="{ row }">
+            <el-icon v-if="row.icon" :size="16">
+              <component :is="row.icon" />
+            </el-icon>
+          </template>
+        </el-table-column>
+        <el-table-column prop="permission" label="权限标识" min-width="150" />
+        <el-table-column prop="sort" label="排序" width="80" align="center" />
+        <el-table-column prop="status" label="状态" width="80" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.status === '正常' ? 'success' : 'danger'">{{ row.status }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" min-width="200" fixed="right" align="center">
+          <template #default="{ row }">
+            <template v-if="row.type !== 2">
+              <el-button v-permission="'sys:menu:add'" type="primary" link @click="handleAdd(row)"
+                >新增</el-button
+              >
+            </template>
+            <el-button v-permission="'sys:menu:edit'" type="primary" link @click="handleEdit(row)"
+              >编辑</el-button
+            >
+            <el-button
+              v-permission="'sys:menu:delete'"
+              type="danger"
+              link
+              @click="handleDelete(row)"
+              >删除</el-button
+            >
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 新增/编辑对话框 -->
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
+      <el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
+        <el-form-item label="上级菜单">
+          <el-input :model-value="parentMenuName" disabled />
+        </el-form-item>
+
+        <el-form-item label="菜单类型" prop="type">
+          <el-radio-group v-model="formData.type" :disabled="isEdit" @change="handleTypeChange">
+            <el-radio :value="0">目录</el-radio>
+            <el-radio :value="1">菜单</el-radio>
+            <el-radio :value="2">按钮</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="菜单名称" prop="name">
+          <el-input v-model="formData.name" placeholder="请输入菜单名称" />
+        </el-form-item>
+
+        <template v-if="formData.type !== 2">
+          <el-form-item label="图标" prop="icon">
+            <el-input v-model="formData.icon" placeholder="请输入图标名称" />
+          </el-form-item>
+        </template>
+
+        <template v-if="formData.type === 1">
+          <el-form-item label="路由路径" prop="path">
+            <el-input v-model="formData.path" placeholder="请输入路由路径" />
+          </el-form-item>
+          <el-form-item label="组件路径" prop="component">
+            <el-input v-model="formData.component" placeholder="请输入组件路径" />
+          </el-form-item>
+        </template>
+
+        <template v-if="formData.type === 2">
+          <el-form-item label="权限标识" prop="permission">
+            <el-input v-model="formData.permission" placeholder="请输入权限标识,如 sys:user:add" />
+          </el-form-item>
+        </template>
+
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="formData.sort" :min="0" :max="999" controls-position="right" />
+        </el-form-item>
+
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="formData.status">
+            <el-radio value="正常">正常</el-radio>
+            <el-radio value="停用">停用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button type="primary" class="is-gradient" @click="handleSubmit">确定</el-button>
+          <el-button @click="dialogVisible = false">取消</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import type { FormInstance, FormRules } from 'element-plus'
+  import { CirclePlus, Search, Refresh, Sort } from '@element-plus/icons-vue'
+
+  // ==================== 类型定义 ====================
+
+  interface MenuItem {
+    id: number
+    name: string
+    type: number // 0=目录 1=菜单 2=按钮
+    icon: string
+    path: string
+    component: string
+    permission: string
+    sort: number
+    status: string
+    children: MenuItem[]
+  }
+
+  // ==================== 表格数据 ====================
+
+  const tableRef = ref<InstanceType<(typeof import('element-plus'))['ElTable']>>()
+  const searchKeyword = ref('')
+  const isExpandAll = ref(true)
+
+  const menuData = ref<MenuItem[]>([
+    {
+      id: 1,
+      name: '首页',
+      type: 1,
+      icon: 'HomeFilled',
+      path: '/dashboard',
+      component: 'dashboard/index',
+      permission: '',
+      sort: 0,
+      status: '正常',
+      children: [],
+    },
+    {
+      id: 200,
+      name: 'SDK管理',
+      type: 1,
+      icon: 'Box',
+      path: '/sdk',
+      component: 'sdk/index',
+      permission: '',
+      sort: 5,
+      status: '正常',
+      children: [
+        {
+          id: 201,
+          name: '管理详情',
+          type: 1,
+          icon: 'Box',
+          path: 'detail',
+          component: 'sdk/detail',
+          permission: '',
+          sort: 1,
+          status: '正常',
+          children: [],
+        },
+      ],
+    },
+    {
+      id: 100,
+      name: '系统管理',
+      type: 0,
+      icon: 'Setting',
+      path: '/system',
+      component: '',
+      permission: '',
+      sort: 10,
+      status: '正常',
+      children: [
+        {
+          id: 101,
+          name: '用户管理',
+          type: 1,
+          icon: 'User',
+          path: 'user',
+          component: 'system/user/index',
+          permission: '',
+          sort: 1,
+          status: '正常',
+          children: [
+            {
+              id: 1011,
+              name: '新增用户',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:user:add',
+              sort: 1,
+              status: '正常',
+              children: [],
+            },
+            {
+              id: 1012,
+              name: '编辑用户',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:user:edit',
+              sort: 2,
+              status: '正常',
+              children: [],
+            },
+            {
+              id: 1013,
+              name: '删除用户',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:user:delete',
+              sort: 3,
+              status: '正常',
+              children: [],
+            },
+          ],
+        },
+        {
+          id: 102,
+          name: '角色管理',
+          type: 1,
+          icon: 'UserFilled',
+          path: 'role',
+          component: 'system/role/index',
+          permission: '',
+          sort: 2,
+          status: '正常',
+          children: [
+            {
+              id: 1021,
+              name: '新增角色',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:role:add',
+              sort: 1,
+              status: '正常',
+              children: [],
+            },
+            {
+              id: 1022,
+              name: '编辑角色',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:role:edit',
+              sort: 2,
+              status: '正常',
+              children: [],
+            },
+            {
+              id: 1023,
+              name: '删除角色',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:role:delete',
+              sort: 3,
+              status: '正常',
+              children: [],
+            },
+            {
+              id: 1024,
+              name: '分配权限',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:role:assign',
+              sort: 4,
+              status: '正常',
+              children: [],
+            },
+          ],
+        },
+        {
+          id: 103,
+          name: '菜单管理',
+          type: 1,
+          icon: 'Menu',
+          path: 'menu',
+          component: 'system/menu/index',
+          permission: '',
+          sort: 3,
+          status: '正常',
+          children: [
+            {
+              id: 1031,
+              name: '新增菜单',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:menu:add',
+              sort: 1,
+              status: '正常',
+              children: [],
+            },
+            {
+              id: 1032,
+              name: '编辑菜单',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:menu:edit',
+              sort: 2,
+              status: '正常',
+              children: [],
+            },
+            {
+              id: 1033,
+              name: '删除菜单',
+              type: 2,
+              icon: '',
+              path: '',
+              component: '',
+              permission: 'sys:menu:delete',
+              sort: 3,
+              status: '正常',
+              children: [],
+            },
+          ],
+        },
+      ],
+    },
+    {
+      id: 300,
+      name: '内容管理',
+      type: 0,
+      icon: 'Document',
+      path: '/content',
+      component: '',
+      permission: '',
+      sort: 20,
+      status: '正常',
+      children: [
+        {
+          id: 301,
+          name: '文章管理',
+          type: 1,
+          icon: 'Document',
+          path: 'article',
+          component: 'content/article/index',
+          permission: '',
+          sort: 1,
+          status: '正常',
+          children: [],
+        },
+      ],
+    },
+  ])
+
+  // 搜索过滤(递归)
+  const filteredMenuData = computed(() => {
+    if (!searchKeyword.value) return menuData.value
+
+    const keyword = searchKeyword.value.toLowerCase()
+
+    function filterTree(items: MenuItem[]): MenuItem[] {
+      return items
+        .map((item) => ({ ...item, children: filterTree(item.children) }))
+        .filter((item) => {
+          const nameMatch = item.name.toLowerCase().includes(keyword)
+          return nameMatch || item.children.length > 0
+        })
+    }
+
+    return filterTree(menuData.value)
+  })
+
+  // ==================== 展开/折叠 ====================
+
+  function toggleExpand() {
+    isExpandAll.value = !isExpandAll.value
+    // 重新渲染表格以应用展开/折叠
+    const data = menuData.value
+    menuData.value = []
+    nextTick(() => {
+      menuData.value = data
+    })
+  }
+
+  // ==================== 搜索/重置 ====================
+
+  function handleSearch() {
+    // filteredMenuData is computed, searchKeyword triggers it
+  }
+
+  function handleReset() {
+    searchKeyword.value = ''
+  }
+
+  // ==================== 对话框逻辑 ====================
+
+  const dialogVisible = ref(false)
+  const isEdit = ref(false)
+  const currentRow = ref<MenuItem | null>(null)
+  const formRef = ref<FormInstance>()
+
+  const dialogTitle = computed(() => (isEdit.value ? '编辑菜单' : '新增菜单'))
+
+  const parentMenuName = computed(() => {
+    if (!currentRow.value) return '主目录'
+    return currentRow.value.name
+  })
+
+  function getInitialFormData(): {
+    parentId: number
+    type: number
+    name: string
+    icon: string
+    path: string
+    component: string
+    permission: string
+    sort: number
+    status: string
+  } {
+    return {
+      parentId: 0,
+      type: 0,
+      name: '',
+      icon: '',
+      path: '',
+      component: '',
+      permission: '',
+      sort: 0,
+      status: '正常',
+    }
+  }
+
+  const formData = reactive(getInitialFormData())
+
+  const formRules = computed<FormRules>(() => ({
+    type: [{ required: true, message: '请选择菜单类型', trigger: 'change' }],
+    name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
+    path:
+      formData.type === 1 ? [{ required: true, message: '请输入路由路径', trigger: 'blur' }] : [],
+    component:
+      formData.type === 1 ? [{ required: true, message: '请输入组件路径', trigger: 'blur' }] : [],
+    permission:
+      formData.type === 2 ? [{ required: true, message: '请输入权限标识', trigger: 'blur' }] : [],
+  }))
+
+  function handleTypeChange() {
+    // 清除切换类型后不相关的字段
+    if (formData.type === 0) {
+      formData.path = ''
+      formData.component = ''
+      formData.permission = ''
+    } else if (formData.type === 1) {
+      formData.permission = ''
+    } else {
+      formData.icon = ''
+      formData.path = ''
+      formData.component = ''
+    }
+  }
+
+  function handleAdd(row?: MenuItem) {
+    isEdit.value = false
+    currentRow.value = row ?? null
+    Object.assign(formData, getInitialFormData())
+    if (row) {
+      formData.parentId = row.id
+      // 子级类型默认比父级深一层
+      if (row.type === 0) formData.type = 1
+      else if (row.type === 1) formData.type = 2
+    }
+    dialogVisible.value = true
+  }
+
+  function handleEdit(row: MenuItem) {
+    isEdit.value = true
+    currentRow.value = null
+    Object.assign(formData, getInitialFormData())
+    formData.type = row.type
+    formData.name = row.name
+    formData.icon = row.icon
+    formData.path = row.path
+    formData.component = row.component
+    formData.permission = row.permission
+    formData.sort = row.sort
+    formData.status = row.status
+    dialogVisible.value = true
+  }
+
+  function handleDelete(row: MenuItem) {
+    ElMessageBox.confirm(`确认要删除菜单「${row.name}」吗?`, '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
+      .then(() => {
+        // 从树中递归移除
+        removeNode(menuData.value, row.id)
+        ElMessage.success('删除成功')
+      })
+      .catch(() => {})
+  }
+
+  function removeNode(nodes: MenuItem[], id: number): boolean {
+    const idx = nodes.findIndex((n) => n.id === id)
+    if (idx !== -1) {
+      nodes.splice(idx, 1)
+      return true
+    }
+    for (const node of nodes) {
+      if (removeNode(node.children, id)) return true
+    }
+    return false
+  }
+
+  async function handleSubmit() {
+    if (!formRef.value) return
+    await formRef.value.validate((valid) => {
+      if (valid) {
+        if (isEdit.value) {
+          ElMessage.success('编辑成功(模拟)')
+        } else {
+          ElMessage.success('新增成功(模拟)')
+        }
+        dialogVisible.value = false
+      }
+    })
+  }
+</script>
+
+<style lang="scss" scoped>
+  .menu-management {
+    .table-card {
+      margin-top: 10px;
+    }
+
+    .table-toolbar {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 16px;
+    }
+
+    .toolbar-left,
+    .toolbar-right {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+  }
+</style>

+ 440 - 17
src/views/system/role/index.vue

@@ -1,23 +1,446 @@
-<script setup lang="ts">
-// 角色管理页面
-</script>
-
 <template>
-  <div class="role-management">
-    <h1>角色管理</h1>
-    <p>角色管理功能开发中...</p>
+  <div>
+    <Breadcrumb />
+    <TableCard
+      v-model:page-no="queryParams.pageNo"
+      v-model:page-size="queryParams.pageSize"
+      :data="filteredData"
+      :columns="columns"
+      :total="total"
+      @change="handlePageChange"
+      @selection-change="handleSelectionChange"
+    >
+      <template #toolbar-left>
+        <el-button
+          v-permission="'sys:role:add'"
+          type="primary"
+          :icon="CirclePlus"
+          size="large"
+          class="is-solid"
+          @click="handleAdd"
+        >
+          新增
+        </el-button>
+        <el-button
+          v-permission="'sys:role:delete'"
+          type="primary"
+          :icon="Delete"
+          size="large"
+          class="is-solid"
+          :disabled="selectedRows.length === 0"
+          @click="handleBatchDelete"
+        >
+          删除
+        </el-button>
+        <el-button
+          type="primary"
+          :icon="Refresh"
+          size="large"
+          class="is-solid"
+          @click="handleRefresh"
+        >
+          刷新
+        </el-button>
+      </template>
+      <template #toolbar-right>
+        <el-input
+          v-model="searchKeyword"
+          :prefix-icon="Search"
+          size="large"
+          placeholder="请输入角色名称或权限字符"
+          style="width: 300px"
+          clearable
+        />
+      </template>
+
+      <template #column-status="{ row }">
+        <el-tag :type="row.status === '正常' ? 'success' : 'danger'">
+          {{ row.status }}
+        </el-tag>
+      </template>
+
+      <template #column-operation="{ row }">
+        <el-button v-permission="'sys:role:edit'" type="primary" link @click="handleEdit(row)">
+          编辑
+        </el-button>
+        <el-button v-permission="'sys:role:delete'" type="danger" link @click="handleDelete(row)">
+          删除
+        </el-button>
+        <el-button v-permission="'sys:role:assign'" type="primary" link @click="handleAssign(row)">
+          分配权限
+        </el-button>
+      </template>
+    </TableCard>
+
+    <!-- 新增/编辑角色对话框 -->
+    <el-dialog
+      v-model="roleDialogVisible"
+      :title="isEdit ? '编辑角色' : '新增角色'"
+      width="500px"
+      destroy-on-close
+    >
+      <el-form ref="formRef" :model="roleForm" :rules="formRules" label-width="80px">
+        <el-form-item label="角色名称" prop="roleName">
+          <el-input v-model="roleForm.roleName" placeholder="请输入角色名称" />
+        </el-form-item>
+        <el-form-item label="权限字符" prop="roleKey">
+          <el-input v-model="roleForm.roleKey" placeholder="请输入权限字符" />
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="roleForm.sort" :min="0" :max="999" />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-switch v-model="roleForm.status" active-text="正常" inactive-text="停用" />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="roleForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="roleDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 分配权限对话框 -->
+    <el-dialog v-model="assignDialogVisible" title="分配权限" width="500px" destroy-on-close>
+      <el-tree
+        ref="treeRef"
+        :data="menuTree"
+        show-checkbox
+        check-strictly
+        node-key="id"
+        default-expand-all
+        :props="{ label: 'label', children: 'children' }"
+      />
+      <template #footer>
+        <el-button @click="assignDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleAssignSubmit">确定</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
-<style scoped lang="scss">
-.role-management {
-  h1 {
-    color: #fff;
-    margin: 0 0 16px;
+<script setup lang="ts">
+  import TableCard from '@/components/TableCard/index.vue'
+  import { CirclePlus, Delete, Search, Refresh } from '@element-plus/icons-vue'
+  import { ElMessageBox } from 'element-plus'
+  import type { FormInstance, FormRules } from 'element-plus'
+
+  // ========== 类型定义 ==========
+  interface RoleRow {
+    id: number
+    roleName: string
+    roleKey: string
+    sort: number
+    status: string
+    remark: string
+    createTime: string
+    menuIds: number[]
+  }
+
+  interface RoleForm {
+    id?: number
+    roleName: string
+    roleKey: string
+    sort: number
+    status: boolean
+    remark: string
   }
-  p {
-    color: rgba(255, 255, 255, 0.65);
-    margin: 0;
+
+  interface MenuItem {
+    id: number
+    label: string
+    children?: MenuItem[]
   }
-}
-</style>
+
+  // ========== 表格配置 ==========
+  const columns = [
+    { type: 'selection' as const, width: 55 },
+    { prop: 'roleName', label: '角色名称', minWidth: 120 },
+    { prop: 'roleKey', label: '权限字符', minWidth: 120 },
+    { prop: 'sort', label: '排序', minWidth: 80 },
+    { prop: 'status', label: '状态', minWidth: 80 },
+    { prop: 'createTime', label: '创建时间', minWidth: 160 },
+    { prop: 'operation', label: '操作', minWidth: 280, isAction: true },
+  ]
+
+  // ========== Mock 数据 ==========
+  const tableData = ref<RoleRow[]>([
+    {
+      id: 1,
+      roleName: '超级管理员',
+      roleKey: 'admin',
+      sort: 1,
+      status: '正常',
+      remark: '拥有所有权限',
+      createTime: '2024-01-01 10:00:00',
+      menuIds: [
+        1, 100, 101, 1011, 1012, 1013, 102, 1021, 1022, 1023, 1024, 103, 1031, 1032, 1033, 200, 300,
+        301,
+      ],
+    },
+    {
+      id: 2,
+      roleName: '内容编辑',
+      roleKey: 'editor',
+      sort: 2,
+      status: '正常',
+      remark: '内容管理权限',
+      createTime: '2024-01-15 14:30:00',
+      menuIds: [1, 200, 300, 301],
+    },
+    {
+      id: 3,
+      roleName: '运营人员',
+      roleKey: 'operator',
+      sort: 3,
+      status: '正常',
+      remark: '运营相关权限',
+      createTime: '2024-02-01 09:00:00',
+      menuIds: [1, 300, 301],
+    },
+    {
+      id: 4,
+      roleName: '普通用户',
+      roleKey: 'user',
+      sort: 4,
+      status: '正常',
+      remark: '基本查看权限',
+      createTime: '2024-02-20 11:15:00',
+      menuIds: [1],
+    },
+    {
+      id: 5,
+      roleName: '已禁用角色',
+      roleKey: 'disabled',
+      sort: 5,
+      status: '停用',
+      remark: '已禁用的测试角色',
+      createTime: '2024-03-01 16:45:00',
+      menuIds: [],
+    },
+  ])
+
+  // ========== 搜索 & 分页 ==========
+  const searchKeyword = ref('')
+  const selectedRows = ref<RoleRow[]>([])
+
+  const queryParams = reactive({
+    pageNo: 1,
+    pageSize: 10,
+  })
+
+  const filteredData = computed(() => {
+    if (!searchKeyword.value) return tableData.value
+    const keyword = searchKeyword.value.toLowerCase()
+    return tableData.value.filter(
+      (row) =>
+        row.roleName.toLowerCase().includes(keyword) || row.roleKey.toLowerCase().includes(keyword)
+    )
+  })
+
+  const total = computed(() => filteredData.value.length)
+
+  function handlePageChange(page: number, size: number) {
+    queryParams.pageNo = page
+    queryParams.pageSize = size
+  }
+
+  function handleSelectionChange(selection: RoleRow[]) {
+    selectedRows.value = selection
+  }
+
+  function handleRefresh() {
+    searchKeyword.value = ''
+    queryParams.pageNo = 1
+    ElMessage.success('刷新成功')
+  }
+
+  // ========== 新增/编辑角色 ==========
+  const roleDialogVisible = ref(false)
+  const isEdit = ref(false)
+  const formRef = ref<FormInstance>()
+
+  const createDefaultForm = (): RoleForm => ({
+    roleName: '',
+    roleKey: '',
+    sort: 0,
+    status: true,
+    remark: '',
+  })
+
+  const roleForm = reactive<RoleForm>(createDefaultForm())
+
+  const formRules = reactive<FormRules>({
+    roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
+    roleKey: [{ required: true, message: '请输入权限字符', trigger: 'blur' }],
+  })
+
+  function handleAdd() {
+    isEdit.value = false
+    Object.assign(roleForm, createDefaultForm())
+    roleDialogVisible.value = true
+  }
+
+  function handleEdit(row: RoleRow) {
+    isEdit.value = true
+    Object.assign(roleForm, {
+      id: row.id,
+      roleName: row.roleName,
+      roleKey: row.roleKey,
+      sort: row.sort,
+      status: row.status === '正常',
+      remark: row.remark,
+    })
+    roleDialogVisible.value = true
+  }
+
+  function handleSubmit() {
+    formRef.value?.validate((valid) => {
+      if (!valid) return
+
+      const statusText = roleForm.status ? '正常' : '停用'
+      const now = new Date()
+      const pad = (n: number) => String(n).padStart(2, '0')
+      const formattedDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
+
+      if (isEdit.value && roleForm.id) {
+        const index = tableData.value.findIndex((r) => r.id === roleForm.id)
+        if (index !== -1) {
+          tableData.value[index] = {
+            ...tableData.value[index],
+            roleName: roleForm.roleName,
+            roleKey: roleForm.roleKey,
+            sort: roleForm.sort,
+            status: statusText,
+            remark: roleForm.remark,
+          }
+        }
+        ElMessage.success('修改成功')
+      } else {
+        const newId =
+          tableData.value.length > 0 ? Math.max(...tableData.value.map((r) => r.id)) + 1 : 1
+        tableData.value.push({
+          id: newId,
+          roleName: roleForm.roleName,
+          roleKey: roleForm.roleKey,
+          sort: roleForm.sort,
+          status: statusText,
+          remark: roleForm.remark,
+          createTime: formattedDate,
+          menuIds: [],
+        })
+        ElMessage.success('新增成功')
+      }
+
+      roleDialogVisible.value = false
+    })
+  }
+
+  // ========== 删除 ==========
+  function handleDelete(row: RoleRow) {
+    ElMessageBox.confirm(`确认删除角色「${row.roleName}」?`, '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
+      .then(() => {
+        const index = tableData.value.findIndex((r) => r.id === row.id)
+        if (index !== -1) {
+          tableData.value.splice(index, 1)
+          ElMessage.success('删除成功')
+        }
+      })
+      .catch(() => {})
+  }
+
+  function handleBatchDelete() {
+    ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 个角色?`, '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
+      .then(() => {
+        const ids = new Set(selectedRows.value.map((r) => r.id))
+        tableData.value = tableData.value.filter((r) => !ids.has(r.id))
+        selectedRows.value = []
+        ElMessage.success('删除成功')
+      })
+      .catch(() => {})
+  }
+
+  // ========== 分配权限 ==========
+  const assignDialogVisible = ref(false)
+  const currentAssignRole = ref<RoleRow | null>(null)
+  const treeRef = ref<{
+    getCheckedKeys: () => number[]
+    setCheckedKeys: (keys: number[]) => void
+  }>()
+
+  const menuTree = ref<MenuItem[]>([
+    { id: 1, label: '首页', children: [] },
+    {
+      id: 100,
+      label: '系统管理',
+      children: [
+        {
+          id: 101,
+          label: '用户管理',
+          children: [
+            { id: 1011, label: '新增用户' },
+            { id: 1012, label: '编辑用户' },
+            { id: 1013, label: '删除用户' },
+          ],
+        },
+        {
+          id: 102,
+          label: '角色管理',
+          children: [
+            { id: 1021, label: '新增角色' },
+            { id: 1022, label: '编辑角色' },
+            { id: 1023, label: '删除角色' },
+            { id: 1024, label: '分配权限' },
+          ],
+        },
+        {
+          id: 103,
+          label: '菜单管理',
+          children: [
+            { id: 1031, label: '新增菜单' },
+            { id: 1032, label: '编辑菜单' },
+            { id: 1033, label: '删除菜单' },
+          ],
+        },
+      ],
+    },
+    { id: 200, label: 'SDK管理', children: [] },
+    {
+      id: 300,
+      label: '内容管理',
+      children: [{ id: 301, label: '文章管理' }],
+    },
+  ])
+
+  function handleAssign(row: RoleRow) {
+    currentAssignRole.value = row
+    assignDialogVisible.value = true
+    nextTick(() => {
+      treeRef.value?.setCheckedKeys(row.menuIds)
+    })
+  }
+
+  function handleAssignSubmit() {
+    if (currentAssignRole.value) {
+      const checkedKeys = treeRef.value?.getCheckedKeys() ?? []
+      const index = tableData.value.findIndex((r) => r.id === currentAssignRole.value!.id)
+      if (index !== -1) {
+        tableData.value[index].menuIds = checkedKeys
+      }
+      ElMessage.success('权限分配成功')
+    }
+    assignDialogVisible.value = false
+  }
+</script>
+
+<style lang="scss" scoped></style>

+ 354 - 17
src/views/system/user/index.vue

@@ -1,23 +1,360 @@
-<script setup lang="ts">
-// 用户管理页面
-</script>
-
 <template>
-  <div class="user-management">
-    <h1>用户管理</h1>
-    <p>用户管理功能开发中...</p>
+  <div>
+    <Breadcrumb />
+    <TableCard
+      v-model:page-no="queryParams.pageNo"
+      v-model:page-size="queryParams.pageSize"
+      :data="filteredData"
+      :columns="columns"
+      :total="total"
+      @change="handlePageChange"
+      @selection-change="handleSelectionChange"
+    >
+      <template #toolbar-left>
+        <el-button
+          v-permission="'sys:user:add'"
+          type="primary"
+          :icon="CirclePlus"
+          size="large"
+          class="is-solid"
+          @click="handleAdd"
+          >新增用户</el-button
+        >
+        <el-button
+          v-permission="'sys:user:delete'"
+          type="primary"
+          :icon="Delete"
+          size="large"
+          class="is-solid"
+          :disabled="selectedRows.length === 0"
+          @click="handleBatchDelete"
+          >删除</el-button
+        >
+        <el-button
+          type="primary"
+          :icon="Refresh"
+          size="large"
+          class="is-solid"
+          @click="handleRefresh"
+          >刷新</el-button
+        >
+      </template>
+      <template #toolbar-right>
+        <el-input
+          v-model="searchKeyword"
+          :prefix-icon="Search"
+          size="large"
+          placeholder="请输入用户名或昵称搜索"
+          style="width: 300px"
+          clearable
+        />
+        <el-button :icon="Search" type="primary" class="is-solid" size="large" @click="handleSearch"
+          >搜索</el-button
+        >
+      </template>
+
+      <template #column-roles="{ row }">
+        <el-tag v-for="role in row.roleList" :key="role" size="small" style="margin-right: 4px">
+          {{ role }}
+        </el-tag>
+      </template>
+
+      <template #column-status="{ row }">
+        <el-tag :type="row.status === '正常' ? 'success' : 'danger'">
+          {{ row.status }}
+        </el-tag>
+      </template>
+
+      <template #column-operation="{ row }">
+        <el-button v-permission="'sys:user:edit'" type="primary" link @click="handleEdit(row)"
+          >编辑</el-button
+        >
+        <el-button v-permission="'sys:user:delete'" type="danger" link @click="handleDelete(row)"
+          >删除</el-button
+        >
+      </template>
+    </TableCard>
+
+    <!-- 新增/编辑用户对话框 -->
+    <el-dialog
+      v-model="dialogVisible"
+      :title="isEdit ? '编辑用户' : '新增用户'"
+      width="500px"
+      destroy-on-close
+    >
+      <el-form ref="formRef" :model="userForm" :rules="formRules" label-width="80px">
+        <el-form-item label="用户名" prop="username">
+          <el-input v-model="userForm.username" placeholder="请输入用户名" :disabled="isEdit" />
+        </el-form-item>
+        <el-form-item label="昵称" prop="nickname">
+          <el-input v-model="userForm.nickname" placeholder="请输入昵称" />
+        </el-form-item>
+        <el-form-item v-if="!isEdit" label="密码" prop="password">
+          <el-input
+            v-model="userForm.password"
+            type="password"
+            show-password
+            placeholder="请输入密码"
+          />
+        </el-form-item>
+        <el-form-item label="角色" prop="roleKeys">
+          <el-select
+            v-model="userForm.roleKeys"
+            multiple
+            placeholder="请选择角色"
+            style="width: 100%"
+          >
+            <el-option label="admin" value="admin" />
+            <el-option label="editor" value="editor" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-switch v-model="userForm.statusEnabled" active-text="正常" inactive-text="停用" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
-<style scoped lang="scss">
-.user-management {
-  h1 {
-    color: #fff;
-    margin: 0 0 16px;
+<script setup lang="ts">
+  import TableCard from '@/components/TableCard/index.vue'
+  import { CirclePlus, Delete, Refresh, Search } from '@element-plus/icons-vue'
+  import { ElMessage, ElMessageBox } from 'element-plus'
+  import type { FormInstance, FormRules } from 'element-plus'
+
+  // ========== 类型定义 ==========
+  interface UserRow {
+    id: number
+    username: string
+    nickname: string
+    roleList: string[]
+    status: string
+    createTime: string
+  }
+
+  interface UserForm {
+    id?: number
+    username: string
+    nickname: string
+    password: string
+    roleKeys: string[]
+    statusEnabled: boolean
   }
-  p {
-    color: rgba(255, 255, 255, 0.65);
-    margin: 0;
+
+  // ========== 表格配置 ==========
+  const columns = [
+    { type: 'selection' as const, width: 55 },
+    { prop: 'username', label: '用户名', minWidth: 120 },
+    { prop: 'nickname', label: '昵称', minWidth: 120 },
+    { prop: 'roles', label: '角色', minWidth: 150 },
+    { prop: 'status', label: '状态', minWidth: 80 },
+    { prop: 'createTime', label: '创建时间', minWidth: 160 },
+    { prop: 'operation', label: '操作', minWidth: 200, isAction: true },
+  ]
+
+  // ========== Mock 数据 ==========
+  const tableData = ref<UserRow[]>([
+    {
+      id: 1,
+      username: 'admin',
+      nickname: '超级管理员',
+      roleList: ['admin'],
+      status: '正常',
+      createTime: '2024-01-01 10:00:00',
+    },
+    {
+      id: 2,
+      username: 'editor',
+      nickname: '内容编辑',
+      roleList: ['editor'],
+      status: '正常',
+      createTime: '2024-01-15 14:30:00',
+    },
+    {
+      id: 3,
+      username: 'zhangsan',
+      nickname: '张三',
+      roleList: ['editor'],
+      status: '正常',
+      createTime: '2024-02-01 09:00:00',
+    },
+    {
+      id: 4,
+      username: 'lisi',
+      nickname: '李四',
+      roleList: ['admin', 'editor'],
+      status: '正常',
+      createTime: '2024-02-20 11:15:00',
+    },
+    {
+      id: 5,
+      username: 'wangwu',
+      nickname: '王五',
+      roleList: [],
+      status: '停用',
+      createTime: '2024-03-01 16:45:00',
+    },
+  ])
+
+  // ========== 搜索 & 分页 ==========
+  const searchKeyword = ref('')
+  const selectedRows = ref<UserRow[]>([])
+
+  const queryParams = reactive({
+    pageNo: 1,
+    pageSize: 10,
+  })
+
+  const filteredData = computed(() => {
+    if (!searchKeyword.value) return tableData.value
+    const keyword = searchKeyword.value.toLowerCase()
+    return tableData.value.filter(
+      (row) =>
+        row.username.toLowerCase().includes(keyword) || row.nickname.toLowerCase().includes(keyword)
+    )
+  })
+
+  const total = computed(() => filteredData.value.length)
+
+  function handlePageChange(page: number, size: number) {
+    queryParams.pageNo = page
+    queryParams.pageSize = size
+  }
+
+  function handleSelectionChange(selection: UserRow[]) {
+    selectedRows.value = selection
+  }
+
+  function handleRefresh() {
+    searchKeyword.value = ''
+    queryParams.pageNo = 1
+    ElMessage.success('刷新成功')
   }
-}
-</style>
+
+  function handleSearch() {
+    queryParams.pageNo = 1
+  }
+
+  // ========== 新增/编辑 ==========
+  const dialogVisible = ref(false)
+  const isEdit = ref(false)
+  const formRef = ref<FormInstance>()
+
+  const createDefaultForm = (): UserForm => ({
+    username: '',
+    nickname: '',
+    password: '',
+    roleKeys: [],
+    statusEnabled: true,
+  })
+
+  const userForm = reactive<UserForm>(createDefaultForm())
+
+  const formRules = reactive<FormRules>({
+    username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
+    nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
+    password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
+    roleKeys: [{ required: true, type: 'array', message: '请选择角色', trigger: 'change' }],
+  })
+
+  function handleAdd() {
+    isEdit.value = false
+    Object.assign(userForm, createDefaultForm())
+    dialogVisible.value = true
+  }
+
+  function handleEdit(row: UserRow) {
+    isEdit.value = true
+    Object.assign(userForm, {
+      id: row.id,
+      username: row.username,
+      nickname: row.nickname,
+      password: '',
+      roleKeys: [...row.roleList],
+      statusEnabled: row.status === '正常',
+    })
+    dialogVisible.value = true
+  }
+
+  function handleSubmit() {
+    formRef.value?.validate((valid) => {
+      if (!valid) return
+
+      const statusText = userForm.statusEnabled ? '正常' : '停用'
+
+      if (isEdit.value && userForm.id) {
+        const index = tableData.value.findIndex((r) => r.id === userForm.id)
+        if (index !== -1) {
+          tableData.value[index] = {
+            ...tableData.value[index],
+            nickname: userForm.nickname,
+            roleList: [...userForm.roleKeys],
+            status: statusText,
+          }
+        }
+        ElMessage.success('修改成功')
+      } else {
+        const newId = Math.max(...tableData.value.map((r) => r.id)) + 1
+        tableData.value.push({
+          id: newId,
+          username: userForm.username,
+          nickname: userForm.nickname,
+          roleList: [...userForm.roleKeys],
+          status: statusText,
+          createTime: new Date()
+            .toLocaleString('zh-CN', {
+              year: 'numeric',
+              month: '2-digit',
+              day: '2-digit',
+              hour: '2-digit',
+              minute: '2-digit',
+              second: '2-digit',
+              hour12: false,
+            })
+            .replace(/\//g, '-'),
+        })
+        ElMessage.success('新增成功')
+      }
+
+      dialogVisible.value = false
+    })
+  }
+
+  // ========== 删除 ==========
+  function handleDelete(row: UserRow) {
+    ElMessageBox.confirm(`确认删除用户「${row.nickname}」?`, '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
+      .then(() => {
+        const index = tableData.value.findIndex((r) => r.id === row.id)
+        if (index !== -1) {
+          tableData.value.splice(index, 1)
+          ElMessage.success('删除成功')
+        }
+      })
+      .catch(() => {})
+  }
+
+  function handleBatchDelete() {
+    ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 个用户?`, '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
+      .then(() => {
+        const ids = new Set(selectedRows.value.map((r) => r.id))
+        tableData.value = tableData.value.filter((r) => !ids.has(r.id))
+        selectedRows.value = []
+        ElMessage.success('删除成功')
+      })
+      .catch(() => {})
+  }
+</script>
+
+<style lang="scss" scoped></style>