Vue 3 后台管理系统前端骨架小案例1.0版本
📋 文章摘要
本文详细介绍了基于 Vue 3 的后台管理系统前端项目实现,涵盖以下核心内容:
🎯 核心功能
- 路由嵌套架构:使用
ParentLayout.vue实现/user和/order模块的嵌套路由 - 动态菜单系统:
MenuTree.vue组件递归渲染路由配置,支持无限级嵌套 - 响应式布局:
MainLayout.vue提供侧边栏、面包屑导航和内容区域分离布局 - 完整页面示例:包含仪表盘、用户管理、订单管理、系统设置等典型后台页面
🛠️ 技术栈
- Vue 3 + Vue Router
- Composition API (
<script setup>) - TypeScript 类型支持 (
env.d.ts) - 模块化 CSS (Scoped Styles)
📁 项目结构
router/- 路由配置(支持嵌套路由、元数据驱动)layout/- 布局组件(主布局、菜单树、父级路由出口)views/- 页面组件(5个典型后台页面)- 完整的样式设计和交互🚀 快速开始
- 一键运行:
bash npm install && npm run devdev` - 访问
http://localhost:5173即可体验完整功能
💡 扩展性
文章最后提供了权限控制、路由懒加载、面包屑组件封装、页面切换动画、状态管理等5个扩展思路,方便项目后续演进。
下面是完整的可运行代码,复制即可使用。
📁 完整项目结构
src/ ├── router/ │ └── index.js ├── layout/ │ ├── MainLayout.vue │ ├── MenuTree.vue │ └── ParentLayout.vue // 父级路由出口组件 ├── views/ │ ├── Dashboard.vue │ ├── UserList.vue │ ├── UserDetail.vue │ ├── OrderList.vue │ └── Settings.vue ├── App.vue ├── main.js └── env.d.ts1.src/router/index.js
import{createRouter,createWebHistory}from'vue-router'importDashboardfrom'../views/Dashboard.vue'importUserListfrom'../views/UserList.vue'importUserDetailfrom'../views/UserDetail.vue'importOrderListfrom'../views/OrderList.vue'importSettingsfrom'../views/Settings.vue'importParentLayoutfrom'../layout/ParentLayout.vue'constroutes=[{path:'/',redirect:'/dashboard'},{path:'/dashboard',name:'Dashboard',component:Dashboard,meta:{title:'仪表盘',icon:'📊'}},{path:'/user',component:ParentLayout,meta:{title:'用户管理',icon:'👤'},children:[{path:'/user/list',// 绝对路径name:'UserList',component:UserList,meta:{title:'用户列表',icon:'📋'}},{path:'/user/detail/:id?',// 绝对路径name:'UserDetail',component:UserDetail,meta:{title:'用户详情',icon:'📄'}}]},{path:'/order',component:ParentLayout,meta:{title:'订单管理',icon:'📦'},children:[{path:'/order/list',// 绝对路径name:'OrderList',component:OrderList,meta:{title:'订单列表',icon:'📋'}}]},{path:'/settings',name:'Settings',component:Settings,meta:{title:'系统设置',icon:'⚙️'}}]constrouter=createRouter({history:createWebHistory(),routes})exportdefaultrouterexport{routes}2.src/layout/MainLayout.vue
<template> <div class="app-container"> <!-- 侧边栏 --> <aside class="sidebar"> <div class="logo">✨ 后台系统</div> <nav class="menu"> <MenuTree :routes="menuRoutes" /> </nav> </aside> <!-- 主区域 --> <main class="main"> <header class="header"> <div class="breadcrumb"> <span v-for="(crumb, index) in breadcrumbs" :key="index"> {{ crumb }} <span v-if="index < breadcrumbs.length - 1" class="separator"> / </span> </span> </div> <div class="user-info"> <span class="avatar">👤</span> <span class="name">管理员</span> </div> </header> <section class="content"> <router-view /> </section> </main> </div> </template> <script setup> import { computed } from 'vue' import { useRoute } from 'vue-router' import { routes } from '@/router' import MenuTree from './MenuTree.vue' const route = useRoute() const menuRoutes = computed(() => { return routes.filter(r => r.path !== '/' && r.meta?.title) }) const breadcrumbs = computed(() => { return route.matched.map(m => m.meta?.title).filter(Boolean) }) </script> <style scoped> * { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; } .app-container { display: flex; height: 100vh; background: #f0f2f5; } /* ===== 侧边栏 ===== */ .sidebar { width: 240px; background: #2d3a4b; display: flex; flex-direction: column; flex-shrink: 0; } .logo { height: 60px; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: #fff; border-bottom: 1px solid #1f2d3d; background: #1f2d3d; letter-spacing: 2px; } .menu { flex: 1; overflow-y: auto; padding: 8px 0; } /* ===== 主区域 ===== */ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .header { height: 60px; background: #fff; border-bottom: 1px solid #e4e7ed; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); } .breadcrumb { font-size: 14px; color: #606266; } .breadcrumb .separator { margin: 0 4px; color: #c0c4cc; } .breadcrumb span:last-child { color: #409eff; font-weight: 500; } .user-info { display: flex; align-items: center; gap: 8px; } .user-info .avatar { font-size: 24px; } .user-info .name { font-size: 14px; color: #303133; } .content { flex: 1; padding: 20px; overflow-y: auto; background: #f0f2f5; } /* ===== 滚动条美化 ===== */ .menu::-webkit-scrollbar, .content::-webkit-scrollbar { width: 4px; } .menu::-webkit-scrollbar-thumb { background: #4a5a6e; border-radius: 4px; } .content::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 4px; } </style>3.src/layout/MenuTree.vue
<template> <ul class="menu-tree"> <template v-for="route in routes" :key="route.path"> <li v-if="route.children && route.children.length > 0" class="menu-item"> <div class="menu-label" @click="toggle(route.path)"> <span class="icon">{{ route.meta?.icon }}</span> <span class="title">{{ route.meta?.title }}</span> <span class="arrow" :class="{ open: isOpen(route.path) }">▶</span> </div> <ul class="sub-menu" v-show="isOpen(route.path)"> <MenuTree :routes="route.children" /> </ul> </li> <li v-else class="menu-item"> <!-- 使用 route.path(绝对路径) --> <router-link :to="route.path" class="menu-label" active-class="active"> <span class="icon">{{ route.meta?.icon }}</span> <span class="title">{{ route.meta?.title }}</span> </router-link> </li> </template> </ul> </template> <script setup> import { ref, watch } from 'vue' import { useRoute } from 'vue-router' const props = defineProps({ routes: { type: Array, required: true } }) const route = useRoute() const openMap = ref({}) // 根据当前路由自动展开父级 function initOpenState() { const matched = route.matched matched.forEach(m => { if (m.children && m.children.length > 0) { openMap.value[m.path] = true } }) } initOpenState() function toggle(path) { openMap.value[path] = !openMap.value[path] } function isOpen(path) { return !!openMap.value[path] } // 路由变化时自动展开父级 watch(() => route.path, () => { const matched = route.matched matched.forEach(m => { if (m.children && m.children.length > 0) { openMap.value[m.path] = true } }) }, { immediate: true }) </script> <style scoped> .menu-tree { list-style: none; padding: 0; margin: 0; } .menu-item { font-size: 14px; line-height: 1.5; } .menu-label { display: flex; align-items: center; padding: 0 20px; height: 44px; color: #bfcbd9; text-decoration: none; cursor: pointer; transition: all 0.3s; position: relative; } .menu-label:hover { background: #1f2d3d; color: #fff; } .menu-label .icon { width: 20px; margin-right: 10px; text-align: center; font-size: 16px; } .menu-label .title { flex: 1; } .menu-label .arrow { font-size: 12px; transition: transform 0.3s; margin-left: 8px; } .menu-label .arrow.open { transform: rotate(90deg); } /* 激活的菜单项 */ .router-link-active { background: #1f2d3d; color: #fff; border-right: 3px solid #409eff; } /* 子菜单缩进 */ .sub-menu { list-style: none; padding: 0; margin: 0; background: #1f2d3d; } .sub-menu .menu-label { padding-left: 50px; } .sub-menu .sub-menu .menu-label { padding-left: 70px; } </style>4.src/layout/ParentLayout.vue
<template> <router-view /> </template>5.src/views/Dashboard.vue
<template> <div> <h2 style="margin-bottom: 20px;">📊 仪表盘</h2> <div class="card-grid"> <div class="card" v-for="i in 4" :key="i"> <div class="card-title">数据 {{ i }}</div> <div class="card-value">{{ Math.floor(Math.random() * 1000) }}</div> <div class="card-desc">较昨日 +{{ Math.floor(Math.random() * 10) }}%</div> </div> </div> </div> </template> <style scoped> .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; } .card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); } .card-title { color: #909399; font-size: 14px; } .card-value { font-size: 28px; font-weight: 600; margin: 10px 0; } .card-desc { color: #67c23a; font-size: 13px; } </style>6.src/views/UserList.vue
<template> <div> <h2>👥 用户列表</h2> <table class="table"> <thead> <tr><th>ID</th><th>姓名</th><th>角色</th><th>状态</th></tr> </thead> <tbody> <tr v-for="user in users" :key="user.id"> <td>{{ user.id }}</td> <td>{{ user.name }}</td> <td>{{ user.role }}</td> <td> <span class="tag" :class="user.status === 'active' ? 'tag-success' : 'tag-danger'"> {{ user.status === 'active' ? '启用' : '禁用' }} </span> </td> </tr> </tbody> </table> </div> </template> <script setup> const users = [ { id: 1, name: '张三', role: '管理员', status: 'active' }, { id: 2, name: '李四', role: '普通用户', status: 'active' }, { id: 3, name: '王五', role: '访客', status: 'inactive' } ] </script> <style scoped> .table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); margin-top: 20px; } .table th { background: #f5f7fa; color: #303133; font-weight: 600; padding: 12px 16px; text-align: left; } .table td { padding: 12px 16px; border-bottom: 1px solid #ebeef5; } .table tr:hover td { background: #f5f7fa; } .tag { display: inline-block; padding: 2px 12px; border-radius: 4px; font-size: 12px; } .tag-success { background: #e1f3d8; color: #67c23a; } .tag-danger { background: #fde2e2; color: #f56c6c; } </style>7.src/views/UserDetail.vue
<template> <div> <h2>📄 用户详情</h2> <div style="background:#fff; padding:20px; border-radius:8px; margin-top:20px; box-shadow:0 2px 12px rgba(0,0,0,0.06);"> <p><strong>用户 ID:</strong>{{ $route.params.id || '无' }}</p> <p><strong>姓名:</strong>张三</p> <p><strong>邮箱:</strong>zhangsan@example.com</p> <p><strong>角色:</strong>管理员</p> <p><strong>注册时间:</strong>2024-01-15</p> </div> </div> </template>8.src/views/OrderList.vue
<template> <div> <h2>📦 订单列表</h2> <table class="table"> <thead> <tr><th>订单号</th><th>金额</th><th>状态</th><th>下单时间</th></tr> </thead> <tbody> <tr v-for="order in orders" :key="order.id"> <td>{{ order.id }}</td> <td>¥{{ order.amount.toFixed(2) }}</td> <td> <span class="tag" :class="{ 'tag-success': order.status === '已完成', 'tag-warning': order.status === '配送中', 'tag-danger': order.status === '已取消' }"> {{ order.status }} </span> </td> <td>{{ order.time }}</td> </tr> </tbody> </table> </div> </template> <script setup> const orders = [ { id: 'ORD-001', amount: 199.00, status: '已完成', time: '2024-06-20 14:30' }, { id: 'ORD-002', amount: 299.50, status: '配送中', time: '2024-06-22 09:15' }, { id: 'ORD-003', amount: 59.90, status: '已取消', time: '2024-06-21 16:40' } ] </script> <style scoped> .table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); margin-top: 20px; } .table th { background: #f5f7fa; color: #303133; font-weight: 600; padding: 12px 16px; text-align: left; } .table td { padding: 12px 16px; border-bottom: 1px solid #ebeef5; } .table tr:hover td { background: #f5f7fa; } .tag { display: inline-block; padding: 2px 12px; border-radius: 4px; font-size: 12px; } .tag-success { background: #e1f3d8; color: #67c23a; } .tag-warning { background: #fdf6ec; color: #e6a23c; } .tag-danger { background: #fde2e2; color: #f56c6c; } </style>9.src/views/Settings.vue
<template> <div> <h2>⚙️ 系统设置</h2> <div style="background:#fff; padding:24px; border-radius:8px; margin-top:20px; box-shadow:0 2px 12px rgba(0,0,0,0.06);"> <div style="margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid #ebeef5;"> <label style="display:inline-block; width:100px; color:#606266;">网站名称</label> <input type="text" value="后台管理系统" style="padding:8px 12px; border:1px solid #dcdfe6; border-radius:4px; width:300px;"> </div> <div style="margin-bottom:16px; padding-bottom:16px; border-bottom:1px solid #ebeef5;"> <label style="display:inline-block; width:100px; color:#606266;">登录超时</label> <input type="number" value="30" style="padding:8px 12px; border:1px solid #dcdfe6; border-radius:4px; width:100px;"> 分钟 </div> <div> <button style="padding:8px 24px; background:#409eff; color:#fff; border:none; border-radius:4px; cursor:pointer;">保存设置</button> </div> </div> </div> </template>10.src/App.vue
<template> <MainLayout /> </template> <script setup> import MainLayout from './layout/MainLayout.vue' </script>11.src/main.js
import{createApp}from'vue'importAppfrom'./App.vue'importrouterfrom'./router'constapp=createApp(App)app.use(router)app.mount('#app')12.src/env.d.ts
/// <reference types="vite/client" />declaremodule'*.vue'{importtype{DefineComponent}from'vue'constcomponent:DefineComponent<{},{},any>exportdefaultcomponent}🚀 运行
npminstallnpmrun dev访问http://localhost:5173,点击左侧菜单即可看到对应的页面内容。
💡 项目总结与扩展思路
项目核心要点总结
路由嵌套架构清晰:通过
ParentLayout.vue作为父级路由出口,实现了/user和/order等模块的嵌套路由结构,使代码组织更加模块化。菜单动态生成:
MenuTree.vue组件递归渲染路由配置,支持无限级嵌套菜单,并实现了路由匹配时自动展开父级菜单的功能。布局组件分离:
MainLayout.vue作为主布局容器,将侧边栏、面包屑导航和内容区域分离,提高了组件的可维护性和复用性。绝对路径路由:所有子路由均使用绝对路径(如
/user/list),避免了相对路径可能带来的混淆,使路由跳转更加直观。元数据驱动:路由配置中的
meta字段(title、icon)驱动了菜单显示和面包屑导航,实现了配置与展示的分离。
后续扩展思路
1. 添加路由权限控制
- 实现方案:在路由守卫中根据用户角色动态过```javascript码示例**:
// router/index.js 中添加 meta.roles 字段{path:'/admin',component:AdminPage,meta:{title:'管理员页面',roles:['admin']}}// 路由守卫中检查权限router.beforeEach((to,from,next)=>{constuserRoles=getUserRoles()if(to.meta.roles&&!to.meta.roles.some(role=>userRoles.includes(role))){next('/403')// 无权限页面}else{next()}})2. 实现路由懒加载
- 优化目的:减少首屏加载时间,按```javascript现方式**:
// 修改路由配置,使用动态导入constroutes=[{path:'/dashboard',name:'Dashboard',component:()=>import('../views/Dashboard.vue'),meta:{title:'仪表盘',icon:'📊'}},// ... 其他路由同理]3. 封装面包屑导航组件
- 当前问题:面包屑逻辑直接写在 `MainLayout.vue```vue性差
- 改进方案:
<!-- Breadcrumb.vue --> <template> <div class="breadcrumb"> <router-link v-for="(item, index) in items" :key="index" :to="item.path" :class="{ 'last-item': index === items.length - 1 }"> {{ item.title }} <span v-if="index < items.length - 1" class="separator"> / </span> </router-link> </div> </template> <script setup> import { computed } from 'vue' import { useRoute } from 'vue-router' const route = useRoute() const items = computed(() => { return route.matched .filter(record => record.meta?.title) .map(record => ({ path: record.path, title: record.meta.title })) }) </script>4. 添加页面切换动画
- 实现效果:路由切换时添加淡```vue动画
- 实现方式:
<!-- 在 MainLayout.vue 的 content 区域 --> <template> <section class="content"> <router-view v-slot="{ Component }"> <transition name="fade" mode="out-in"> <component :is="Component" /> </transition> </router-view> </section> </template> <style> .fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } </style>5. 集成状态管理(Pinia)
- 应用场景:用户信息、全局配置```javascript基础示例**:
// stores/user.jsimport{defineStore}from'pinia'exportconstuseUserStore=defineStore('user',{state:()=>({name:'管理员',avatar:'👤',permissions:[]}),actions:{updateUserInfo(info){this.name=info.namethis.avatar=info.avatar}}})总结建议
本项目已搭建了一个功能完整的 Vue 3 后台管理系统前端骨架,具备清晰的模块划分和良好的扩展性。建议在实际开发中:
- 按需扩展:根据项目规模选择上述扩展思路,避免过度设计
- 保持一致性:新增功能时遵循现有的代码风格和架构模式
- 渐进式优化:优先实现业务需求,再逐步进行性能优化
通过以上扩展,可以进一步提升项目的可维护性、用户体验和性能表现。
看到对应的页面内容。
