Explorar o código

feat: 实现后台管理系统基础架构与权限控制

新增登录页面、动态路由、权限控制及基础布局组件
添加系统管理、内容管理相关页面
实现基于角色的菜单过滤和路由守卫
完善类型定义和Mock数据接口
优化布局样式和交互体验
piks hai 1 semana
pai
achega
050541c250

+ 2 - 1
.gitignore

@@ -28,4 +28,5 @@ dist-ssr
 .kiro
 .opencode
 .trae
-.github
+.github
+/docs

+ 3 - 3
components.d.ts

@@ -11,18 +11,18 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
-    ElAvatar: typeof import('element-plus/es')['ElAvatar']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+    ElForm: typeof import('element-plus/es')['ElForm']
+    ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElInput: typeof import('element-plus/es')['ElInput']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
-    ElSwitch: typeof import('element-plus/es')['ElSwitch']
-    HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
   }

+ 20 - 7
src/layout/components/AppMain.vue

@@ -1,18 +1,31 @@
+<script setup lang="ts">
+// AppMain 组件 - 主内容区域
+</script>
+
 <template>
   <main class="app-main">
-    Main Content Area
+    <router-view v-slot="{ Component }">
+      <transition name="fade" mode="out-in">
+        <component :is="Component" />
+      </transition>
+    </router-view>
   </main>
 </template>
 
-<script setup lang="ts">
-// 应用主内容区域组件逻辑
-</script>
-
-<style lang="scss" scoped>
+<style scoped lang="scss">
 .app-main {
   height: 100%;
-  width: 100%;
   padding: 20px;
   box-sizing: border-box;
 }
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.2s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
 </style>

+ 49 - 10
src/layout/components/Header.vue

@@ -1,21 +1,60 @@
+<script setup lang="ts">
+import { useUserStore } from '@/store/modules/user'
+
+const userStore = useUserStore()
+
+function logout() {
+  userStore.logout()
+}
+</script>
+
 <template>
   <header class="header">
-    Header Area
+    <div class="header-left">
+      <h1 class="title">后台管理系统</h1>
+    </div>
+    <div class="header-right">
+      <el-dropdown>
+        <div class="user-info">
+          <el-icon><User /></el-icon>
+          <span>{{ userStore.name || 'admin' }}</span>
+        </div>
+        <template #dropdown>
+          <el-dropdown-menu>
+            <el-dropdown-item>个人中心</el-dropdown-item>
+            <el-dropdown-item divided @click="logout">退出登录</el-dropdown-item>
+          </el-dropdown-menu>
+        </template>
+      </el-dropdown>
+    </div>
   </header>
 </template>
 
-<script setup lang="ts">
-// 头部组件逻辑
-</script>
-
-<style lang="scss" scoped>
+<style scoped lang="scss">
 .header {
-  height: 100%;
-  width: 100%;
   display: flex;
+  justify-content: space-between;
   align-items: center;
+  height: 100%;
   padding: 0 20px;
-  box-sizing: border-box;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+  color: #fff;
+
+  .title {
+    font-size: 20px;
+    font-weight: 600;
+    margin: 0;
+  }
+
+  .user-info {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    cursor: pointer;
+    color: rgba(255, 255, 255, 0.85);
+
+    &:hover {
+      color: #fff;
+    }
+  }
 }
 </style>

+ 74 - 0
src/layout/components/Sidebar/SidebarItem.vue

@@ -0,0 +1,74 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { MenuItem } from '@/types/menu'
+
+interface Props {
+  item: MenuItem
+  basePath?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  basePath: ''
+})
+
+// 判断是否有可见的子菜单
+const hasChildren = computed(() => {
+  return props.item.children && props.item.children.length > 0
+})
+
+// 过滤隐藏的子菜单
+const visibleChildren = computed(() => {
+  return props.item.children?.filter(child => !child.meta?.hidden) || []
+})
+
+// 解决路径
+const resolvePath = computed(() => {
+  return resolveBasePath(props.item.path)
+})
+
+function resolveBasePath(path: string) {
+  // 如果 path 已经是绝对路径,直接返回,不再拼接
+  if (path.startsWith('/')) {
+    return path
+  }
+  if (props.basePath) {
+    return `${props.basePath}/${path}`.replace(/\/+/g, '/')
+  }
+  return path
+}
+</script>
+
+<template>
+  <div v-if="!item.meta?.hidden" class="menu-item">
+    <template v-if="hasChildren">
+      <el-sub-menu :index="item.path">
+        <template #title>
+          <el-icon v-if="item.meta?.icon">
+            <component :is="item.meta.icon" />
+          </el-icon>
+          <span>{{ item.meta?.title }}</span>
+        </template>
+        <SidebarItem
+          v-for="child in visibleChildren"
+          :key="child.path"
+          :item="child"
+          :base-path="resolveBasePath(item.path)"
+        />
+      </el-sub-menu>
+    </template>
+    <template v-else>
+      <el-menu-item :index="resolvePath">
+        <el-icon v-if="item.meta?.icon">
+          <component :is="item.meta.icon" />
+        </el-icon>
+        <span>{{ item.meta?.title }}</span>
+      </el-menu-item>
+    </template>
+  </div>
+</template>
+
+<style scoped>
+.menu-item {
+  /* 自定义样式 */
+}
+</style>

+ 78 - 12
src/layout/components/SiderBar/index.vue

@@ -1,21 +1,87 @@
-<template>
-  <aside class="sidebar">
-    Sidebar Area
-  </aside>
-</template>
-
 <script setup lang="ts">
-// 侧边栏组件逻辑
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+import SidebarItem from '../Sidebar/SidebarItem.vue'
+import { usePermissionStore } from '@/store/modules/permission'
+
+const route = useRoute()
+const permissionStore = usePermissionStore()
+
+// 当前激活的菜单
+const activeMenu = computed(() => {
+  const { meta, path } = route
+  // 如果设置了 activeMenu 就使用它,否则使用当前路径
+  return meta?.activeMenu || path
+})
+
+// 从 permission store 获取菜单列表
+const menuList = computed(() => permissionStore.menuList)
 </script>
 
-<style lang="scss" scoped>
+<template>
+  <div class="sidebar">
+    <div class="sidebar-logo">
+      <span>Admin</span>
+    </div>
+
+    <el-scrollbar class="sidebar-scroll">
+      <el-menu
+        :default-active="activeMenu"
+        :unique-opened="true"
+        mode="vertical"
+        background-color="#1a1a24"
+        text-color="rgba(255,255,255,0.65)"
+        active-text-color="#ffffff"
+        router
+      >
+        <SidebarItem
+          v-for="item in menuList"
+          :key="item.path"
+          :item="item"
+        />
+      </el-menu>
+    </el-scrollbar>
+  </div>
+</template>
+
+<style scoped lang="scss">
 .sidebar {
   height: 100%;
-  width: 100%;
   display: flex;
   flex-direction: column;
-  padding: 20px;
-  box-sizing: border-box;
-  border-right: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.sidebar-logo {
+  height: 88px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+
+  span {
+    color: #fff;
+    font-size: 24px;
+    font-weight: 600;
+  }
+}
+
+.sidebar-scroll {
+  flex: 1;
+  overflow: hidden;
+}
+
+:deep(.el-menu) {
+  border-right: none;
+
+  .el-menu-item,
+  .el-sub-menu__title {
+    &:hover {
+      background-color: #2d2d3d !important;
+    }
+  }
+
+  .el-menu-item.is-active {
+    background-color: #2d2d3d;
+  }
 }
 </style>

+ 15 - 16
src/layout/index.vue

@@ -1,27 +1,26 @@
-<template>
-  <div class="app-wrapper">
-    <SiderBar class="sidebar-container" />
-    <Header class="header-container" />
-    <AppMain class="main-container" />
-  </div>
-</template>
-
 <script setup lang="ts">
 import SiderBar from './components/SiderBar/index.vue'
 import Header from './components/Header.vue'
 import AppMain from './components/AppMain.vue'
 </script>
 
+<template>
+  <div class="app-wrapper">
+    <SiderBar class="sidebar-container" />
+    <div class="main-wrapper">
+      <Header class="header-container" />
+      <AppMain class="main-container" />
+    </div>
+  </div>
+</template>
+
 <style lang="scss" scoped>
 .app-wrapper {
   display: grid;
   width: 100vw;
   height: 100vh;
-  /* 定义列:左侧300px,右侧自适应占据剩余空间 */
   grid-template-columns: 300px 1fr;
-  /* 定义行:顶部88px,底部自适应占据剩余空间 */
   grid-template-rows: 88px 1fr;
-  /* 使用 grid-template-areas 定义布局区域 */
   grid-template-areas:
     "sidebar header"
     "sidebar main";
@@ -30,21 +29,21 @@ import AppMain from './components/AppMain.vue'
 
 .sidebar-container {
   grid-area: sidebar;
-  /* 根据截图大概的深色背景占位,后续可替换 */
   background-color: #1a1a24;
-  color: #fff;
+}
+
+.main-wrapper {
+  display: contents; // 让子元素直接参与 grid 布局
 }
 
 .header-container {
   grid-area: header;
   background-color: #141419;
-  color: #fff;
 }
 
 .main-container {
   grid-area: main;
   background-color: #0d0d12;
-  color: #fff;
-  overflow: auto; /* 内容超出时主区域内部滚动 */
+  overflow: auto;
 }
 </style>

+ 13 - 2
src/main.ts

@@ -1,11 +1,22 @@
 import { createApp } from 'vue'
 import App from './App.vue'
+import router from './router'
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
 import 'element-plus/dist/index.css'
 import 'normalize.css/normalize.css'
-import router from './router'
+
+// ⚠️ 路由守卫必须在 createApp 后、mount 前引入
+import './permission'
 
 const app = createApp(App)
+const pinia = createPinia()
 
-app.use(router)
+// 注册所有 Element Plus 图标(Sidebar 的 icon 字段使用)
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+  app.component(key, component)
+}
 
+app.use(pinia).use(router).use(ElementPlus)
 app.mount('#app')

+ 105 - 0
src/mock/index.ts

@@ -0,0 +1,105 @@
+import type { MenuItem, LoginResponse, UserInfoResponse, RoutesResponse } from '@/types/menu'
+
+// ────────── 模拟用户数据库 ──────────
+const mockUsers = {
+  admin: { token: 'mock-token-admin', roles: ['admin'], 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
+}
+
+// ────────── 模拟后端存储的全量路由 ──────────
+// 真实场景中这份数据存在数据库,后端根据角色过滤后返回
+const allAsyncRoutes: MenuItem[] = [
+  {
+    path: '/dashboard',
+    name: 'Dashboard',
+    component: 'dashboard/index',
+    meta: { title: '首页', icon: 'HomeFilled', roles: ['admin', 'editor'] }
+  },
+  {
+    path: '/system',
+    name: 'System',
+    redirect: '/system/user',
+    meta: { title: '系统管理', icon: 'Setting', roles: ['admin'] },
+    children: [
+      {
+        path: 'user',
+        name: 'SystemUser',
+        component: 'system/user/index',
+        meta: { title: '用户管理', icon: 'User', roles: ['admin'] }
+      },
+      {
+        path: 'role',
+        name: 'SystemRole',
+        component: 'system/role/index',
+        meta: { title: '角色管理', icon: 'UserFilled', roles: ['admin'] }
+      }
+    ]
+  },
+  {
+    path: '/content',
+    name: 'Content',
+    redirect: '/content/article',
+    meta: { title: '内容管理', icon: 'Document', roles: ['admin', 'editor'] },
+    children: [
+      {
+        path: 'article',
+        name: 'Article',
+        component: 'content/article/index',
+        meta: { title: '文章管理', icon: 'Document', roles: ['admin', 'editor'] }
+      }
+    ]
+  }
+]
+
+// ────────── 工具函数 ──────────
+function filterRoutesByRoles(routes: MenuItem[], roles: string[]): MenuItem[] {
+  return routes.reduce<MenuItem[]>((acc, route) => {
+    const allowed = route.meta?.roles
+    const hasPermission = !allowed || roles.some(r => allowed.includes(r))
+    if (hasPermission) {
+      const item = { ...route }
+      if (item.children?.length) {
+        item.children = filterRoutesByRoles(item.children, roles)
+      }
+      acc.push(item)
+    }
+    return acc
+  }, [])
+}
+
+const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
+
+// ────────── Mock 接口 ──────────
+/** 模拟登录 */
+export async function mockLogin(username: string, password: string): Promise<LoginResponse> {
+  await delay(500)
+  const user = mockUsers[username as keyof typeof mockUsers]
+  if (user && password === '123456') {
+    return { code: 200, data: { token: user.token } }
+  }
+  throw new Error('用户名或密码错误')
+}
+
+/** 模拟获取用户信息 */
+export async function mockGetUserInfo(token: string): Promise<UserInfoResponse> {
+  await delay(200)
+  const user = tokenMap[token]
+  if (!user) throw new Error('Token 无效或已过期')
+  return { code: 200, data: { name: user.name, roles: user.roles, avatar: user.avatar } }
+}
+
+/** 模拟获取权限路由(后端根据角色过滤后返回) */
+export async function mockGetRoutes(token: string): Promise<RoutesResponse> {
+  await delay(300)
+  const user = tokenMap[token]
+  if (!user) throw new Error('Token 无效或已过期')
+  // 深拷贝,避免修改原始数据
+  const routes = filterRoutesByRoles(JSON.parse(JSON.stringify(allAsyncRoutes)), user.roles)
+  return { code: 200, data: routes }
+}

+ 0 - 202
src/mock/sidebarRouters.ts

@@ -1,202 +0,0 @@
-// Mock侧边栏路由数据
-export const sidebarRouters = [
-  {
-    path: '/dashboard',
-    name: 'Dashboard',
-    meta: {
-      title: '仪表盘',
-      icon: 'dashboard'
-    },
-    component: () => import('@/views/Home.vue')
-  },
-  // {
-  //   path: '/user',
-  //   name: 'User',
-  //   meta: {
-  //     title: '用户管理',
-  //     icon: 'user'
-  //   },
-  //   component: () => import('@/views/user/index.vue'),
-  //   children: [
-  //     {
-  //       path: 'list',
-  //       name: 'UserList',
-  //       meta: {
-  //         title: '用户列表',
-  //         icon: 'user-list'
-  //       },
-  //       component: () => import('@/views/user/list.vue')
-  //     },
-  //     {
-  //       path: 'role',
-  //       name: 'UserRole',
-  //       meta: {
-  //         title: '角色管理',
-  //         icon: 'role'
-  //       },
-  //       component: () => import('@/views/user/role.vue')
-  //     },
-  //     {
-  //       path: 'permission',
-  //       name: 'UserPermission',
-  //       meta: {
-  //         title: '权限设置',
-  //         icon: 'permission'
-  //       },
-  //       component: () => import('@/views/user/permission.vue')
-  //     }
-  //   ]
-  // },
-  // {
-  //   path: '/order',
-  //   name: 'Order',
-  //   meta: {
-  //     title: '订单管理',
-  //     icon: 'order'
-  //   },
-  //   component: () => import('@/views/order/index.vue'),
-  //   children: [
-  //     {
-  //       path: 'pending',
-  //       name: 'OrderPending',
-  //       meta: {
-  //         title: '待处理订单',
-  //         icon: 'pending'
-  //       },
-  //       component: () => import('@/views/order/pending.vue')
-  //     },
-  //     {
-  //       path: 'shipped',
-  //       name: 'OrderShipped',
-  //       meta: {
-  //         title: '已发货订单',
-  //         icon: 'shipped'
-  //       },
-  //       component: () => import('@/views/order/shipped.vue')
-  //     },
-  //     {
-  //       path: 'completed',
-  //       name: 'OrderCompleted',
-  //       meta: {
-  //         title: '已完成订单',
-  //         icon: 'completed'
-  //       },
-  //       component: () => import('@/views/order/completed.vue')
-  //     },
-  //     {
-  //       path: 'canceled',
-  //       name: 'OrderCanceled',
-  //       meta: {
-  //         title: '已取消订单',
-  //         icon: 'canceled'
-  //       },
-  //       component: () => import('@/views/order/canceled.vue')
-  //     }
-  //   ]
-  // },
-  // {
-  //   path: '/product',
-  //   name: 'Product',
-  //   meta: {
-  //     title: '产品管理',
-  //     icon: 'product'
-  //   },
-  //   component: () => import('@/views/product/index.vue'),
-  //   children: [
-  //     {
-  //       path: 'list',
-  //       name: 'ProductList',
-  //       meta: {
-  //         title: '产品列表',
-  //         icon: 'product-list'
-  //       },
-  //       component: () => import('@/views/product/list.vue')
-  //     },
-  //     {
-  //       path: 'category',
-  //       name: 'ProductCategory',
-  //       meta: {
-  //         title: '产品分类',
-  //         icon: 'category'
-  //       },
-  //       component: () => import('@/views/product/category.vue')
-  //     },
-  //     {
-  //       path: 'brand',
-  //       name: 'ProductBrand',
-  //       meta: {
-  //         title: '产品品牌',
-  //         icon: 'brand'
-  //       },
-  //       component: () => import('@/views/product/brand.vue')
-  //     }
-  //   ]
-  // },
-  // {
-  //   path: '/system',
-  //   name: 'System',
-  //   meta: {
-  //     title: '系统设置',
-  //     icon: 'system'
-  //   },
-  //   component: () => import('@/views/system/index.vue'),
-  //   children: [
-  //     {
-  //       path: 'config',
-  //       name: 'SystemConfig',
-  //       meta: {
-  //         title: '系统配置',
-  //         icon: 'config'
-  //       },
-  //       component: () => import('@/views/system/config.vue')
-  //     },
-  //     {
-  //       path: 'log',
-  //       name: 'SystemLog',
-  //       meta: {
-  //         title: '系统日志',
-  //         icon: 'log'
-  //       },
-  //       component: () => import('@/views/system/log.vue')
-  //     },
-  //     {
-  //       path: 'backup',
-  //       name: 'SystemBackup',
-  //       meta: {
-  //         title: '数据备份',
-  //         icon: 'backup'
-  //       },
-  //       component: () => import('@/views/system/backup.vue')
-  //     }
-  //   ]
-  // },
-  // {
-  //   path: '/external-link',
-  //   name: 'ExternalLink',
-  //   meta: {
-  //     title: '外部链接',
-  //     icon: 'external-link'
-  //   },
-  //   component: () => import('@/views/external/index.vue'),
-  //   children: [
-  //     {
-  //       path: 'github',
-  //       name: 'GithubLink',
-  //       meta: {
-  //         title: 'GitHub',
-  //         icon: 'github'
-  //       },
-  //       component: () => import('@/views/external/github.vue')
-  //     },
-  //     {
-  //       path: 'documentation',
-  //       name: 'DocumentationLink',
-  //       meta: {
-  //         title: '文档中心',
-  //         icon: 'documentation'
-  //       },
-  //       component: () => import('@/views/external/documentation.vue')
-  //     }
-  //   ]
-  // }
-]

+ 60 - 0
src/permission.ts

@@ -0,0 +1,60 @@
+import router, { notFoundRoute } from '@/router'
+import { useUserStore } from '@/store/modules/user'
+import { usePermissionStore } from '@/store/modules/permission'
+
+// 不需要登录即可访问的路径白名单
+const whiteList = ['/login', '/404']
+
+router.beforeEach(async (to, _from, next) => {
+  const userStore = useUserStore()
+  const permissionStore = usePermissionStore()
+
+  const token = userStore.token
+
+  if (token) {
+    // ── 已登录 ──
+    if (to.path === '/login') {
+      // 已登录不允许访问登录页
+      next('/')
+      return
+    }
+
+    if (permissionStore.isRoutesLoaded) {
+      // 路由已加载,直接放行
+      next()
+    } else {
+      try {
+        // ① 获取用户信息(角色、昵称等)
+        await userStore.getUserInfo()
+
+        // ② 拉取并处理权限路由,返回扁平化路由配置
+        const accessRoutes = await permissionStore.generateRoutes(token)
+
+        // ③ 将动态路由注册为 Root(Layout)的子路由
+        accessRoutes.forEach(route => {
+          router.addRoute('Root', route)
+        })
+        
+        // ④ 最后注册 404 路由(确保在权限路由之后)
+        router.addRoute(notFoundRoute)
+
+        // ⑤ 重新导航到目标页面
+        // replace: true 避免在历史记录中留下重定向记录
+        // 必须用 next({ ...to }) 而不是 next(),让路由重新匹配新注册的路由
+        next({ ...to, replace: true })
+      } catch (error) {
+        // Token 失效或接口异常 → 清除 Token,跳转登录
+        console.error('[权限守卫] 初始化失败:', error)
+        userStore.resetToken()
+        next(`/login?redirect=${encodeURIComponent(to.path)}`)
+      }
+    }
+  } else {
+    // ── 未登录 ──
+    if (whiteList.includes(to.path)) {
+      next()
+    } else {
+      next(`/login?redirect=${encodeURIComponent(to.path)}`)
+    }
+  }
+})

+ 36 - 15
src/router/index.ts

@@ -1,22 +1,43 @@
 import { createRouter, createWebHistory } from 'vue-router'
+import type { RouteRecordRaw } from 'vue-router'
 import Layout from '@/layout/index.vue'
 
+/**
+ * 静态路由:无需登录即可访问
+ * 动态路由通过 router.addRoute('Root', route) 在运行时注入
+ */
+export const constantRoutes: RouteRecordRaw[] = [
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('@/views/login/index.vue'),
+    meta: { hidden: true, title: '登录' }
+  },
+  {
+    // Root Layout:所有需要登录的页面都是它的子路由
+    path: '/',
+    name: 'Root', // ← 重要:addRoute 时通过 name 定位父路由
+    component: Layout,
+    redirect: '/dashboard',
+    children: [] // 动态路由在此注入
+  }
+  // 注意:404 路由移到动态添加,避免先于权限路由匹配
+]
+
+/**
+ * 404 路由(动态添加,确保在权限路由之后)
+ */
+export const notFoundRoute: RouteRecordRaw = {
+  path: '/:pathMatch(.*)*',
+  name: 'NotFound',
+  component: () => import('@/views/error/404.vue'),
+  meta: { hidden: true, title: '404' }
+}
+
 const router = createRouter({
   history: createWebHistory(),
-  routes: [
-    {
-      path: '/',
-      component: Layout,
-      children: [
-        {
-          path: '',
-          name: 'Home',
-          // 这里可以替换为实际的主页组件
-          component: () => import('@/views/Home.vue')
-        }
-      ]
-    }
-  ]
+  routes: constantRoutes,
+  scrollBehavior: () => ({ top: 0 })
 })
 
-export default router
+export default router

+ 42 - 0
src/store/modules/permission.ts

@@ -0,0 +1,42 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import type { RouteRecordRaw } from 'vue-router'
+import type { MenuItem } from '@/types/menu'
+import { mockGetRoutes } from '@/mock'
+import { flattenMenuToRoutes } from '@/utils/routeHelper'
+
+export const usePermissionStore = defineStore('permission', () => {
+  /** 用于 Sidebar 渲染的嵌套菜单数据(保持原始层级结构) */
+  const menuList = ref<MenuItem[]>([])
+
+  /** 标记:动态路由是否已加载完毕 */
+  const isRoutesLoaded = ref(false)
+
+  /**
+   * 核心函数:
+   * 1. 从 Mock/后端拉取当前用户有权限的路由
+   * 2. 更新 menuList(供 Sidebar 使用)
+   * 3. 返回扁平化的路由配置(由路由守卫负责注册到 router)
+   */
+  async function generateRoutes(token: string): Promise<RouteRecordRaw[]> {
+    const res = await mockGetRoutes(token)
+    const rawMenuData: MenuItem[] = res.data
+
+    // 保留嵌套结构给 Sidebar 使用
+    menuList.value = rawMenuData
+
+    // 转换为扁平路由,返回给调用方(路由守卫)注册
+    const flatRoutes = flattenMenuToRoutes(rawMenuData)
+
+    isRoutesLoaded.value = true
+    return flatRoutes
+  }
+
+  /** 登出时重置(配合页面刷新使用) */
+  function reset() {
+    menuList.value = []
+    isRoutesLoaded.value = false
+  }
+
+  return { menuList, isRoutesLoaded, generateRoutes, reset }
+})

+ 48 - 0
src/store/modules/user.ts

@@ -0,0 +1,48 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { mockLogin, mockGetUserInfo } from '@/mock'
+
+const TOKEN_KEY = 'app_token'
+
+export const useUserStore = defineStore('user', () => {
+  const token = ref(localStorage.getItem(TOKEN_KEY) || '')
+  const name = ref('')
+  const roles = ref<string[]>([])
+  const avatar = ref('')
+
+  const isLoggedIn = computed(() => !!token.value)
+
+  /** 登录:获取并存储 Token */
+  async function login(username: string, password: string) {
+    const res = await mockLogin(username, password)
+    token.value = res.data.token
+    localStorage.setItem(TOKEN_KEY, res.data.token)
+  }
+
+  /** 获取用户信息(在路由守卫中调用) */
+  async function getUserInfo() {
+    const res = await mockGetUserInfo(token.value)
+    name.value = res.data.name
+    roles.value = res.data.roles
+    avatar.value = res.data.avatar
+    return res.data.roles
+  }
+
+  /** Token 过期时仅清除 Token,不跳转 */
+  function resetToken() {
+    token.value = ''
+    localStorage.removeItem(TOKEN_KEY)
+  }
+
+  /** 登出:清除所有状态,刷新页面清除动态路由 */
+  function logout() {
+    token.value = ''
+    name.value = ''
+    roles.value = []
+    localStorage.removeItem(TOKEN_KEY)
+    // 刷新页面是最可靠的清除动态路由方式
+    window.location.replace('/login')
+  }
+
+  return { token, name, roles, avatar, isLoggedIn, login, getUserInfo, resetToken, logout }
+})

+ 58 - 0
src/types/menu.ts

@@ -0,0 +1,58 @@
+/**
+ * 路由元信息类型定义
+ */
+export interface RouteMeta {
+  title: string
+  icon?: string
+  hidden?: boolean // 是否在菜单中隐藏
+  roles?: string[] // 允许访问的角色列表
+  keepAlive?: boolean // 是否缓存页面
+  activeMenu?: string // 高亮指定的菜单路径(用于详情页)
+}
+
+/**
+ * 菜单项类型定义
+ */
+export interface MenuItem {
+  path: string
+  name?: string
+  component?: string // 对应 views 下的文件路径(字符串)
+  redirect?: string
+  meta?: RouteMeta
+  children?: MenuItem[]
+}
+
+/**
+ * 用户信息类型定义
+ */
+export interface UserInfo {
+  name: string
+  roles: string[]
+  avatar: string
+}
+
+/**
+ * 登录响应类型
+ */
+export interface LoginResponse {
+  code: number
+  data: {
+    token: string
+  }
+}
+
+/**
+ * 用户信息响应类型
+ */
+export interface UserInfoResponse {
+  code: number
+  data: UserInfo
+}
+
+/**
+ * 路由响应类型
+ */
+export interface RoutesResponse {
+  code: number
+  data: MenuItem[]
+}

+ 184 - 184
src/utils/http.ts

@@ -1,184 +1,184 @@
-import axios from 'axios'
-import { ElNotification, ElMessageBox, ElMessage, ElLoading } from 'element-plus'
-import { getToken } from '@/utils/auth'
-import errorCode from '@/utils/errorCode'
-import { tansParams, blobValidate } from '@/utils/ruoyi'
-import cache from '@/plugins/cache'
-import { saveAs } from 'file-saver'
-import useUserStore from '@/store/modules/user'
-
-const TIMEOUT = 10000
-const REPEAT_SUBMIT_DEFAULT_INTERVAL = 1000
-const CACHE_SIZE_LIMIT = 5 * 1024 * 1024
-const ERROR_MESSAGE_DURATION = 5000
-
-let downloadLoadingInstance: any
-export let isRelogin = { show: false }
-
-axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
-
-const service = axios.create({
-  baseURL: import.meta.env.VITE_APP_BASE_API,
-  timeout: TIMEOUT
-})
-
-service.interceptors.request.use(
-  config => {
-    const isToken = (config.headers || {}).isToken === false
-    const isRepeatSubmit = (config.headers || {}).repeatSubmit === false
-    const interval = (config.headers || {}).interval || REPEAT_SUBMIT_DEFAULT_INTERVAL
-
-    if (getToken() && !isToken) {
-      config.headers['Authorization'] = 'Bearer ' + getToken()
-    }
-
-    if (config.method === 'get' && config.params) {
-      let url = config.url + '?' + tansParams(config.params)
-      url = url.slice(0, -1)
-      config.params = {}
-      config.url = url
-    }
-
-    if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
-      const requestObj = {
-        url: config.url,
-        data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,
-        time: new Date().getTime()
-      }
-      const requestSize = JSON.stringify(requestObj).length
-
-      if (requestSize >= CACHE_SIZE_LIMIT) {
-        console.warn(`[${config.url}]: 请求数据大小超出允许的5M限制,无法进行防重复提交验证。`)
-        return config
-      }
-
-      const sessionObj = cache.session.getJSON('sessionObj')
-      if (!sessionObj) {
-        cache.session.setJSON('sessionObj', requestObj)
-      } else {
-        const { url: s_url, data: s_data, time: s_time } = sessionObj
-        if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {
-          const message = '数据正在处理,请勿重复提交'
-          console.warn(`[${s_url}]: ${message}`)
-          return Promise.reject(new Error(message))
-        }
-        cache.session.setJSON('sessionObj', requestObj)
-      }
-    }
-    return config
-  },
-  error => {
-    console.log(error)
-    return Promise.reject(error)
-  }
-)
-
-service.interceptors.response.use(
-  res => {
-    const code = res.data.code || 200
-    const msg = errorCode[code] || res.data.msg || errorCode['default']
-
-    if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
-      return res.data
-    }
-
-    const codeHandlers = {
-      401: () => {
-        if (!isRelogin.show) {
-          isRelogin.show = true
-          ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', {
-            confirmButtonText: '重新登录',
-            cancelButtonText: '取消',
-            type: 'warning'
-          })
-            .then(() => {
-              isRelogin.show = false
-              useUserStore()
-                .logOut()
-                .then(() => {
-                  location.href = '/index'
-                })
-            })
-            .catch(() => {
-              isRelogin.show = false
-            })
-        }
-        return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
-      },
-      500: () => {
-        ElMessage({ message: msg, type: 'error' })
-        return Promise.reject(new Error(msg))
-      },
-      601: () => {
-        ElMessage({ message: msg, type: 'warning' })
-        return Promise.reject(new Error(msg))
-      }
-    }
-
-    if (codeHandlers[code]) {
-      return codeHandlers[code]()
-    }
-
-    if (code !== 200) {
-      ElNotification.error({ title: msg })
-      return Promise.reject('error')
-    }
-
-    return Promise.resolve(res.data)
-  },
-  error => {
-    console.log('err' + error)
-    let { message } = error
-
-    const errorMessages = {
-      'Network Error': '后端接口连接异常',
-      timeout: '系统接口请求超时',
-      'Request failed with status code': `系统接口${message.slice(-3)}异常`
-    }
-
-    for (const [key, value] of Object.entries(errorMessages)) {
-      if (message.includes(key)) {
-        message = value
-        break
-      }
-    }
-
-    ElMessage({ message, type: 'error', duration: ERROR_MESSAGE_DURATION })
-    return Promise.reject(error)
-  }
-)
-
-export function download(url, params, filename, config) {
-  downloadLoadingInstance = ElLoading.service({
-    text: '正在下载数据,请稍候',
-    background: 'rgba(0, 0, 0, 0.7)'
-  })
-
-  return service
-    .post(url, params, {
-      transformRequest: [params => tansParams(params)],
-      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-      responseType: 'blob',
-      ...config
-    })
-    .then(async data => {
-      const isBlob = blobValidate(data)
-      if (isBlob) {
-        const blob = new Blob([data])
-        saveAs(blob, filename)
-      } else {
-        const resText = await data.text()
-        const rspObj = JSON.parse(resText)
-        const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default']
-        ElMessage.error(errMsg)
-      }
-      downloadLoadingInstance.close()
-    })
-    .catch(r => {
-      console.error(r)
-      ElMessage.error('下载文件出现错误,请联系管理员!')
-      downloadLoadingInstance.close()
-    })
-}
-
-export default service
+// import axios from 'axios'
+// import { ElNotification, ElMessageBox, ElMessage, ElLoading } from 'element-plus'
+// import { getToken } from '@/utils/auth'
+// import errorCode from '@/utils/errorCode'
+// import { tansParams, blobValidate } from '@/utils/ruoyi'
+// import cache from '@/plugins/cache'
+// import { saveAs } from 'file-saver'
+// import useUserStore from '@/store/modules/user'
+
+// const TIMEOUT = 10000
+// const REPEAT_SUBMIT_DEFAULT_INTERVAL = 1000
+// const CACHE_SIZE_LIMIT = 5 * 1024 * 1024
+// const ERROR_MESSAGE_DURATION = 5000
+
+// let downloadLoadingInstance: any
+// export let isRelogin = { show: false }
+
+// axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
+
+// const service = axios.create({
+//   baseURL: import.meta.env.VITE_APP_BASE_API,
+//   timeout: TIMEOUT
+// })
+
+// service.interceptors.request.use(
+//   config => {
+//     const isToken = (config.headers || {}).isToken === false
+//     const isRepeatSubmit = (config.headers || {}).repeatSubmit === false
+//     const interval = (config.headers || {}).interval || REPEAT_SUBMIT_DEFAULT_INTERVAL
+
+//     if (getToken() && !isToken) {
+//       config.headers['Authorization'] = 'Bearer ' + getToken()
+//     }
+
+//     if (config.method === 'get' && config.params) {
+//       let url = config.url + '?' + tansParams(config.params)
+//       url = url.slice(0, -1)
+//       config.params = {}
+//       config.url = url
+//     }
+
+//     if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
+//       const requestObj = {
+//         url: config.url,
+//         data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,
+//         time: new Date().getTime()
+//       }
+//       const requestSize = JSON.stringify(requestObj).length
+
+//       if (requestSize >= CACHE_SIZE_LIMIT) {
+//         console.warn(`[${config.url}]: 请求数据大小超出允许的5M限制,无法进行防重复提交验证。`)
+//         return config
+//       }
+
+//       const sessionObj = cache.session.getJSON('sessionObj')
+//       if (!sessionObj) {
+//         cache.session.setJSON('sessionObj', requestObj)
+//       } else {
+//         const { url: s_url, data: s_data, time: s_time } = sessionObj
+//         if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {
+//           const message = '数据正在处理,请勿重复提交'
+//           console.warn(`[${s_url}]: ${message}`)
+//           return Promise.reject(new Error(message))
+//         }
+//         cache.session.setJSON('sessionObj', requestObj)
+//       }
+//     }
+//     return config
+//   },
+//   error => {
+//     console.log(error)
+//     return Promise.reject(error)
+//   }
+// )
+
+// service.interceptors.response.use(
+//   res => {
+//     const code = res.data.code || 200
+//     const msg = errorCode[code] || res.data.msg || errorCode['default']
+
+//     if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
+//       return res.data
+//     }
+
+//     const codeHandlers = {
+//       401: () => {
+//         if (!isRelogin.show) {
+//           isRelogin.show = true
+//           ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', {
+//             confirmButtonText: '重新登录',
+//             cancelButtonText: '取消',
+//             type: 'warning'
+//           })
+//             .then(() => {
+//               isRelogin.show = false
+//               useUserStore()
+//                 .logOut()
+//                 .then(() => {
+//                   location.href = '/index'
+//                 })
+//             })
+//             .catch(() => {
+//               isRelogin.show = false
+//             })
+//         }
+//         return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
+//       },
+//       500: () => {
+//         ElMessage({ message: msg, type: 'error' })
+//         return Promise.reject(new Error(msg))
+//       },
+//       601: () => {
+//         ElMessage({ message: msg, type: 'warning' })
+//         return Promise.reject(new Error(msg))
+//       }
+//     }
+
+//     if (codeHandlers[code]) {
+//       return codeHandlers[code]()
+//     }
+
+//     if (code !== 200) {
+//       ElNotification.error({ title: msg })
+//       return Promise.reject('error')
+//     }
+
+//     return Promise.resolve(res.data)
+//   },
+//   error => {
+//     console.log('err' + error)
+//     let { message } = error
+
+//     const errorMessages = {
+//       'Network Error': '后端接口连接异常',
+//       timeout: '系统接口请求超时',
+//       'Request failed with status code': `系统接口${message.slice(-3)}异常`
+//     }
+
+//     for (const [key, value] of Object.entries(errorMessages)) {
+//       if (message.includes(key)) {
+//         message = value
+//         break
+//       }
+//     }
+
+//     ElMessage({ message, type: 'error', duration: ERROR_MESSAGE_DURATION })
+//     return Promise.reject(error)
+//   }
+// )
+
+// export function download(url, params, filename, config) {
+//   downloadLoadingInstance = ElLoading.service({
+//     text: '正在下载数据,请稍候',
+//     background: 'rgba(0, 0, 0, 0.7)'
+//   })
+
+//   return service
+//     .post(url, params, {
+//       transformRequest: [params => tansParams(params)],
+//       headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+//       responseType: 'blob',
+//       ...config
+//     })
+//     .then(async data => {
+//       const isBlob = blobValidate(data)
+//       if (isBlob) {
+//         const blob = new Blob([data])
+//         saveAs(blob, filename)
+//       } else {
+//         const resText = await data.text()
+//         const rspObj = JSON.parse(resText)
+//         const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default']
+//         ElMessage.error(errMsg)
+//       }
+//       downloadLoadingInstance.close()
+//     })
+//     .catch(r => {
+//       console.error(r)
+//       ElMessage.error('下载文件出现错误,请联系管理员!')
+//       downloadLoadingInstance.close()
+//     })
+// }
+
+// export default service

+ 62 - 0
src/utils/routeHelper.ts

@@ -0,0 +1,62 @@
+import type { RouteRecordRaw } from 'vue-router'
+import type { MenuItem } from '@/types/menu'
+
+// Vite glob 懒加载所有视图组件
+// 注意:此文件位于 src/utils/,../views 指向 src/views
+const viewModules = import.meta.glob('../views/**/*.vue')
+
+/**
+ * 根据字符串路径懒加载 Vue 组件
+ * @param componentPath - 如 'dashboard/index' 或 'system/user/index'
+ */
+export function loadComponent(componentPath: string): () => Promise<unknown> {
+  const key = `../views/${componentPath}.vue`
+  if (viewModules[key]) {
+    return viewModules[key] as () => Promise<unknown>
+  }
+  console.warn(`[路由] 未找到组件: ${key}`)
+  return () => import('../views/error/404.vue')
+}
+
+/**
+ * 路径拼接(兼容绝对路径与相对路径)
+ */
+export function resolvePath(basePath: string, routePath: string): string {
+  if (routePath.startsWith('/')) return routePath
+  return `${basePath}/${routePath}`.replace(/\/+/g, '/')
+}
+
+/**
+ * 将嵌套菜单数据扁平化为 Vue Router 可用的路由配置
+ * 所有动态路由最终挂载在 Root(Layout)下
+ */
+export function flattenMenuToRoutes(
+  menuItems: MenuItem[],
+  parentPath = ''
+): RouteRecordRaw[] {
+  const routes: RouteRecordRaw[] = []
+
+  menuItems.forEach(item => {
+    const fullPath = resolvePath(parentPath, item.path)
+
+    if (item.children && item.children.length > 0) {
+      // 父级菜单:注册 redirect 路由,再递归处理子项
+      routes.push({
+        path: fullPath,
+        redirect: item.redirect || resolvePath(fullPath, item.children[0].path),
+        meta: item.meta
+      } as RouteRecordRaw)
+      routes.push(...flattenMenuToRoutes(item.children, fullPath))
+    } else {
+      // 叶子路由:直接对应一个页面组件
+      routes.push({
+        path: fullPath,
+        name: item.name,
+        component: loadComponent(item.component!),
+        meta: item.meta
+      } as RouteRecordRaw)
+    }
+  })
+
+  return routes
+}

+ 23 - 0
src/views/content/article/index.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+// 文章管理页面
+</script>
+
+<template>
+  <div class="article-management">
+    <h1>文章管理</h1>
+    <p>文章管理功能开发中...</p>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.article-management {
+  h1 {
+    color: #fff;
+    margin: 0 0 16px;
+  }
+  p {
+    color: rgba(255, 255, 255, 0.65);
+    margin: 0;
+  }
+}
+</style>

+ 23 - 0
src/views/dashboard/index.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+// Dashboard 页面
+</script>
+
+<template>
+  <div class="dashboard">
+    <h1>首页</h1>
+    <p>欢迎来到后台管理系统</p>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.dashboard {
+  h1 {
+    color: #fff;
+    margin: 0 0 16px;
+  }
+  p {
+    color: rgba(255, 255, 255, 0.65);
+    margin: 0;
+  }
+}
+</style>

+ 40 - 0
src/views/error/404.vue

@@ -0,0 +1,40 @@
+<script setup lang="ts">
+// 404 页面
+</script>
+
+<template>
+  <div class="error-page">
+    <div class="error-content">
+      <h1>404</h1>
+      <p>页面不存在</p>
+      <el-button type="primary" @click="$router.push('/')">返回首页</el-button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.error-page {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100vh;
+  background: #0d0d12;
+}
+
+.error-content {
+  text-align: center;
+
+  h1 {
+    color: #fff;
+    font-size: 120px;
+    margin: 0;
+    line-height: 1;
+  }
+
+  p {
+    color: rgba(255, 255, 255, 0.65);
+    font-size: 20px;
+    margin: 16px 0 32px;
+  }
+}
+</style>

+ 112 - 0
src/views/login/index.vue

@@ -0,0 +1,112 @@
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
+import { useUserStore } from '@/store/modules/user'
+
+const router = useRouter()
+const route = useRoute()
+const userStore = useUserStore()
+
+const formRef = ref<FormInstance>()
+const loading = ref(false)
+
+const form = reactive({ username: 'admin', password: '123456' })
+
+const rules: FormRules = {
+  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
+  password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
+}
+
+async function handleLogin() {
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+
+  loading.value = true
+  try {
+    await userStore.login(form.username, form.password)
+    const redirect = (route.query.redirect as string) || '/'
+    await router.replace(redirect)
+    ElMessage.success('登录成功')
+  } catch (error: unknown) {
+    ElMessage.error(error instanceof Error ? error.message : '登录失败')
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<template>
+  <div class="login-page">
+    <div class="login-box">
+      <h2 class="login-title">后台管理系统</h2>
+
+      <el-form ref="formRef" :model="form" :rules="rules" size="large">
+        <el-form-item prop="username">
+          <el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
+        </el-form-item>
+        <el-form-item prop="password">
+          <el-input
+            v-model="form.password"
+            type="password"
+            placeholder="密码"
+            prefix-icon="Lock"
+            show-password
+            @keyup.enter="handleLogin"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button
+            type="primary"
+            :loading="loading"
+            style="width: 100%"
+            @click="handleLogin"
+          >
+            登 录
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="login-hint">
+        <p>管理员:admin / 123456 &nbsp;(可见所有菜单)</p>
+        <p>编辑员:editor / 123456 &nbsp;(仅内容管理)</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.login-page {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100vh;
+  background: #0d0d12;
+}
+
+.login-box {
+  width: 420px;
+  padding: 48px 40px;
+  background: #1a1a24;
+  border-radius: 12px;
+  border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.login-title {
+  color: #fff;
+  text-align: center;
+  margin: 0 0 32px;
+  font-size: 22px;
+  font-weight: 600;
+}
+
+.login-hint {
+  margin-top: 16px;
+  text-align: center;
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.3);
+  line-height: 1.8;
+  p { margin: 0; }
+}
+</style>

+ 23 - 0
src/views/system/role/index.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+// 角色管理页面
+</script>
+
+<template>
+  <div class="role-management">
+    <h1>角色管理</h1>
+    <p>角色管理功能开发中...</p>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.role-management {
+  h1 {
+    color: #fff;
+    margin: 0 0 16px;
+  }
+  p {
+    color: rgba(255, 255, 255, 0.65);
+    margin: 0;
+  }
+}
+</style>

+ 23 - 0
src/views/system/user/index.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+// 用户管理页面
+</script>
+
+<template>
+  <div class="user-management">
+    <h1>用户管理</h1>
+    <p>用户管理功能开发中...</p>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.user-management {
+  h1 {
+    color: #fff;
+    margin: 0 0 16px;
+  }
+  p {
+    color: rgba(255, 255, 255, 0.65);
+    margin: 0;
+  }
+}
+</style>