📋 文章摘要
本文详细介绍了基于 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.ts
1. src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import UserList from '../views/UserList.vue'
import UserDetail from '../views/UserDetail.vue'
import OrderList from '../views/OrderList.vue'
import Settings from '../views/Settings.vue'
import ParentLayout from '../layout/ParentLayout.vue'
const routes = [
{
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: '⚙️' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
export { 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'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
12. src/env.d.ts
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
🚀 运行
npm install
npm run 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) => {
const userRoles = getUserRoles()
if (to.meta.roles && !to.meta.roles.some(role => userRoles.includes(role))) {
next('/403') // 无权限页面
} else {
next()
}
})
2. 实现路由懒加载
- 优化目的:减少首屏加载时间,按```javascript现方式**:
// 修改路由配置,使用动态导入
const routes = [
{
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.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
name: '管理员',
avatar: '👤',
permissions: []
}),
actions: {
updateUserInfo(info) {
this.name = info.name
this.avatar = info.avatar
}
}
})
总结建议
本项目已搭建了一个功能完整的 Vue 3 后台管理系统前端骨架,具备清晰的模块划分和良好的扩展性。建议在实际开发中:
- 按需扩展:根据项目规模选择上述扩展思路,避免过度设计
- 保持一致性:新增功能时遵循现有的代码风格和架构模式
- 渐进式优化:优先实现业务需求,再逐步进行性能优化
通过以上扩展,可以进一步提升项目的可维护性、用户体验和性能表现。
看到对应的页面内容。

1万+

被折叠的 条评论
为什么被折叠?



