VUE3+Layui构建后台管理系统:从工程搭建到权限路由全解析
2026/9/16 16:21:49 网站建设 项目流程

简介:基于Vue3与Layui搭建的通用后台管理系统前端源码,适合有一定Vue基础、希望快速构建中后台项目或学习组件封装思路的开发者。资源共2002个文件,以1339个Markdown说明文档和418个JS脚本为主体,配合221个JSON配置及少量HTML/CSS文件,覆盖前后端联调与界面展示所需;其中Markdown文档可作渐进式讲解笔记,JS与JSON便于直接对照修改和调试;压缩包整体204.39MB,方便按模块查阅。系统内置Express模拟后端接口,演示echarts统计图表与DataV大屏监控,并封装下拉选择、图片上传、富文本、列表/表单/详情界面等常用组件。功能上包含登录注册(短信/邮箱)、找回密码、主题切换、暗黑模式、全局搜索、国际化、工作台、系统设置、菜单/角色/用户/数据字典管理、订单与商品管理等模块。已有1724人学习下载,适合作后台管理系统开发的学习参考与二次开发基础。

1. 当 VUE3 遇到 Layui:后台管理系统前端的另一个解题思路

在 2026 年的前端生态里,VUE3 已经没有什么可争论的——Composition API、Vite 构建、TypeScript 加持,几乎成了新项目的事实标准。但真正落到“通用后台管理系统”这个场景时,有一个反直觉的结论值得先摆出来:你不需要再去对比 Element Plus 还是 Ant Design Vue。如果你的团队长期在 jQuery 时代的后端管理系统里沉淀了一套基于 Layui 的交互范式,现在要做的是把 Layui 的能力用 Vue 3 的方式重新表达,而不是推翻重来。

所谓“通用后台管理系统”,对前端而言通常包含四件套:登录与权限、动态菜单与路由、列表页的搜索/表格/分页、表单页的校验与提交。用 VUE3 从头搭建,意味着从工程初始化、环境配置、组件封装到权限路由全部自己掌控,不依赖一个全家桶框架替你决定业务结构。这篇文章直接给出前端篇的完整实现路径:用 Vite 创建 VUE3 工程并接入 Layui 样式体系,把 Layui 经典的 table 与 form 交互封装成 Vue 组件,再挂接路由级权限控制。无论你是在做电商后台管理系统,还是企业内部运营平台,这套骨架都能直接演变出业务页面。

2. 用 Vite 搭建 VUE3 工程并完成 Layui 的环境配置

2.1 创建项目与基础依赖安装

标题里的“从头搭建”意味着不建议使用现成的 vue-element-admin 这类模板,而是用 Vite 官方脚手架生成最干净的工程,再把需要的依赖逐个加进去。这样每一层依赖的关系都清晰,后期维护时不会出现“不知道为什么这个包要存在”的问题。

npm create vite@latest admin-web -- --template vue cd admin-web npm install npm install vue-router@4 pinia npm install layui @layui/layui-vue

完成安装后,先理解一下这里的依赖选型:vue-router@4负责路由,pinia负责全局状态(用户信息和权限集合),layui是原版 Layui 的核心包,提供样式与部分 JS 能力,@layui/layui-vue是社区维护的 Vue 3 封装组件库,内部实现了一层组件化转换。热词里的“layui可以用vue吗”可以在这里直接回答:原版不行,但官方重编译的 layer 确实可以在 Vue 3 里以命令式方式调用,表单和表格组件则需要走layui-vue的封装。

2.2 注册 Layui 组件库并验证样式输出

main.js里注册组件库,同时引入 Layui 的核心样式。这一步容易踩的第一个坑是样式导入顺序——如果先引入了业务样式,再引入 Layui 样式,表格宽度和按钮间距都会被覆盖掉。

import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import LayuiVue from '@layui/layui-vue' import 'layui/dist/css/layui.css' import '@layui/layui-vue/es/index.css' import './assets/main.css' const app = createApp(App) app.use(createPinia()) app.use(router) app.use(LayuiVue) app.mount('#app')

代码说明:app.use(LayuiVue)会全局注册 LayuiVue 旗下所有组件,包括LayTableLayFormLaydate等,代价是打包体积略增,但在后台管理系统这种非首屏体验敏感的场景里完全可接受。如果你后续想优化,可以把use改成按需引入,但这属于进阶话题,后面会提到。

2.3 目录结构与路由前置配置

通用后台管理系统需要关注的可扩展性,在工程结构上就应该体现出来。我一般会在src下拆出api(接口层)、layout(整体布局)、views(页面)、router(路由配置)、store(状态)五个目录。

src/ api/ # 接口请求统一出口 layout/ # 顶部导航 + 侧边栏 + 内容区 views/ # 业务页面 login/ dashboard/ system/ user/ role/ components/ # 页面级复用组件 router/ index.js store/ user.js

路由配置里预先设计两个关键点:一是所有业务页面挂在Layout父路由下,二是动态路由的addRoute要留到权限判断之后执行。这个设计直接支撑了后面第 4 章的动态菜单实现。

提示:Vite 默认的@别名没有启用,需要在vite.config.js里配置resolve.alias,否则import Layout from '@/layout/index.vue'会直接报错。

3. 用 Layui 思维封装通用列表页:搜索区、表格与分页

3.1 为什么把 Layui 的表格交互思想搬进 Vue 组件

Layui 在服务端渲染时代能长期占据后端管理系统的原因,是它对表格、分页和搜索区的交互做了非常强的约定:表格列配置由 JS 对象驱动,数据由后端接口返回,前端只需要维护datacolspage三个变量。这个思想放在 VUE3 里反而更合适,因为响应式数据天然适合这种声明式配置。

我在这里做的封装不是简单搬运组件,而是基于@layui/layui-vueLayTable做一层业务包装,让页面代码里只有搜索项定义、列定义、接口路径,其余全部由封装组件接管。热词里提到的“layui table 单个列能加点击事件”也要在这个封装层解决,后面会专门讲。

3.2 编写通用的 TablePage 包装组件

新建src/views/components/TablePage.vue,接收三个核心 props:apiUrl(列表数据接口)、columns(表格列配置)、searchItems(搜索表单配置)。

<template> <div class="table-page"> <lay-form :model="searchModel" class="search-form"> <lay-input v-for="item in searchItems" :key="item.prop" v-model="searchModel[item.prop]" :placeholder="item.placeholder" allow-clear /> <lay-button type="primary" @click="handleSearch">搜索</lay-button> <lay-button @click="handleReset">重置</lay-button> </lay-form> <lay-table :columns="columns" :data-source="tableData" :page="page" :loading="loading" @change="handlePageChange" @row-click="handleRowClick" /> </div> </template> <script setup> import { ref, reactive, onMounted } from 'vue' import { httpGet } from '@/api/request' const props = defineProps({ apiUrl: { type: String, required: true }, columns: { type: Array, required: true }, searchItems: { type: Array, default: () => [] } }) const searchModel = reactive({}) const tableData = ref([]) const loading = ref(false) const page = reactive({ current: 1, limit: 10, total: 0 }) // 初始化搜索模型,避免 v-model 绑定到 undefined searchItems.forEach(item => { searchModel[item.prop] = item.defaultValue || '' }) async function fetchData() { loading.value = true const params = { pageNum: page.current, pageSize: page.limit, ...searchModel } try { const res = await httpGet(props.apiUrl, { params }) tableData.value = res.data.records page.total = res.data.total } finally { loading.value = false } } function handleSearch() { page.current = 1 fetchData() } function handleReset() { searchItems.forEach(item => { searchModel[item.prop] = item.defaultValue || '' }) handleSearch() } function handlePageChange({ current, limit }) { page.current = current page.limit = limit fetchData() } function handleRowClick(data, index) { // 事件统一抛给父页面 props.columns.forEach(col => { if (col.onClick) col.onClick(data, index) }) } onMounted(fetchData) defineExpose({ fetchData, resetData: handleReset }) </script>

代码逻辑说明:组件对外暴露fetchDataresetData,方便父页面在新增或编辑后刷新表格;行点击事件里遍历列配置,如果某一列配置了onClick回调就调用,这解决了“单个列点击事件”的需求。分页参数统一使用pageNum/pageSize传递,这是大多数后端框架的默认约定,如果你对接的是 Spring Boot + MyBatis Plus,后端可以直接用IPage接收。

3.3 在用户列表页复用并配置列点击事件

以系统管理里的用户列表为例,页面的工作量只剩两块:定义列和定义搜索项。

<template> <TablePage ref="tableRef" api-url="/system/user/list" :columns="columns" :search-items="searchItems" /> </template> <script setup> import { reactive } from 'vue' import TablePage from '@/views/components/TablePage.vue' const columns = reactive([ { title: '用户ID', field: 'userId', width: 100 }, { title: '用户名', field: 'username', width: 150 }, { title: '操作', field: 'operation', width: 200, onClick: (row) => { console.log('点击了操作列,当前行数据是:', row) } } ]) const searchItems = [ { prop: 'username', placeholder: '请输入用户名' }, { prop: 'status', placeholder: '请选择状态' } ] </script>

参数说明:field是列字段名,必须与后端返回的 JSON 字段名一致;title是表头显示文本;width不写则自动平均分配。列配置支持templet自定义列模板,用于状态标签、头像缩略图等场景,本质上是 Layui 的templet: (d) => ...回调在 Vue 侧的同名保留。这样封装的收益在于:后续每个列表页都不需要再写表格和分页逻辑,只配置数据源和列描述。

4. 动态菜单与权限路由:用 Pinia 驱动侧边栏和异步组件

4.1 权限模型的选型:前端动态注册 vs 后端返回菜单树

通用后台管理系统的权限设计,业内长期存在两种做法:一种是前端路由表写死,通过用户角色标识过滤;另一种是后端返回当前用户的菜单树,前端遍历生成路由。热词里提到“vue3父子组件非父子组件交互”,在权限路由场景里最常见的形态就是:用户信息存 Pinia,侧边栏组件和路由守卫都需要读取同一份数据源。

我倾向于后者:后端返回菜单树。原因有两条。其一,菜单树的父子层级天然匹配侧边栏的嵌套折叠结构,前端只需递归渲染;其二,按钮级别的权限(如删除、导出)也能挂在菜单节点上一起返回,避免接口字段越加越散。前端要做的就是把这种树结构转换成 Vue Router 能识别的RouteRecordRaw

4.2 从菜单树生成路由记录的工具函数

新建src/router/dynamic.js,负责把后端菜单数组转换成路由配置。核心映射关系是:菜单的component字段是字符串,对应views目录下的组件路径;children递归处理。

import Layout from '@/layout/index.vue' // 后端 menu 对象 -> 前端路由对象 export function buildRoutes(menus) { const routes = [] for (const menu of menus) { if (menu.type === 'DIRECTORY') { // 目录节点,挂载 Layout 作为父路由 routes.push({ path: '/' + menu.path, component: Layout, meta: { title: menu.menuName, icon: menu.icon }, children: buildChildren(menu.children || []) }) } else if (menu.type === 'MENU') { // 菜单节点,直接是 Layout 的子路由 routes.push({ path: '/' + menu.path, component: Layout, meta: { title: menu.menuName, icon: menu.icon, activeMenu: menu.path }, children: [ { path: '', component: loadView(menu.component), meta: { title: menu.menuName } } ] }) } // type === 'BUTTON' 的节点不生成路由,只用于页面内按钮权限 } return routes } function buildChildren(children) { return children.map(child => ({ path: child.path, component: loadView(child.component), meta: { title: child.menuName, icon: child.icon } })) } // 利用 import.meta.glob 批量加载 views 下所有 .vue 文件 const modules = import.meta.glob('../views/**/*.vue') function loadView(componentPath) { const key = `../views/${componentPath}.vue` if (!modules[key]) { throw new Error(`组件路径未找到: ${key}`) } return modules[key] }

代码说明:import.meta.glob是 Vite 提供的批量导入语法,返回一个path -> loader的映射对象。这里有个关键坑:modules[key]拿到的不是组件本身,而是一个() => import(...)函数,把它丢给component字段正好满足 Vue Router 的异步组件要求。如果后端返回的component字段是system/user/index,那么实际组件文件必须位于src/views/system/user/index.vue,路径对不上时抛出的错误信息可以帮助迅速定位。

4.3 路由守卫里做登录校验和动态注册

src/router/index.js中配置静态路由(登录页、404、根路径)后,在全局前置守卫里完成两件事:TOKEN 存在性检查、菜单未加载时触发动态注册。

import { useUserStore } from '@/store/user' const staticRoutes = [ { path: '/login', component: () => import('@/views/login/index.vue') }, { path: '/', redirect: '/dashboard' }, { path: '/:pathMatch(.*)*', component: () => import('@/views/error/404.vue') } ] router.beforeEach(async (to, from, next) => { const userStore = useUserStore() const token = userStore.token if (!token && to.path !== '/login') { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (token && !userStore.menuLoaded) { // 第一次进入系统,拉取菜单并动态注册 try { const menus = await userStore.fetchMenus() const dynamicRoutes = buildRoutes(menus) dynamicRoutes.forEach(route => { router.addRoute(route) }) // 动态路由注册后,重新进入当前路由,避免刷新空白 next({ ...to, replace: true }) } catch (error) { await userStore.resetState() next({ path: '/login' }) } return } next() })

参数说明:menuLoaded是 Pinia state 里的布尔值,初始为falsefetchMenus成功后置为truenext({ ...to, replace: true })这一行是刷新后页面的关键,因为动态路由注册后直接next()可能会导致第一次导航匹配不到组件而白屏,replace 重进一次就能命中新注册的路由。这里不需要再单独维护一个“已注册路由表”,因为menuLoaded本身就是状态开关。

5. 表单页与交互增强:select 动态赋值和 date 组件边界处理

5.1 动态表单的两种路:纯声明式配置 vs 插槽定制

列表页搞定后,表单页通常有两种形态:一是弹窗内嵌表单,用于新增和编辑,二是独立页面,用于复杂业务。通用后台管理系统里前者占 80%。@layui/layui-vue提供了LayFormLayInput,但动态表单的核心不在 UI 组件,而在“表单项的显隐联动”和“下拉选项的数据来源”。

热词里“layui select动态赋值”是一个高频需求。在 Vue 3 里,正确的思路并不是去操作 DOM 刷新 select 渲染,而是让v-model绑定的数据和选项数组都成为响应式依赖。

<template> <lay-form> <lay-form-item label="用户类型"> <lay-select v-model="form.userType" :options="userTypeOptions" /> </lay-form-item> <lay-form-item v-if="form.userType === 'admin'" label="上级机构"> <lay-select v-model="form.parentOrg" :options="orgOptions" /> </lay-form-item> </lay-form> </template> <script setup> import { ref, watch } from 'vue' import { httpGet } from '@/api/request' const form = ref({ userType: 'normal', parentOrg: undefined }) const userTypeOptions = ref([ { value: 'normal', label: '普通用户' }, { value: 'admin', label: '管理员' } ]) const orgOptions = ref([]) // 监听用户类型变化,动态拉取机构下拉框的数据 watch(() => form.value.userType, async (newVal) => { if (newVal === 'admin') { const res = await httpGet('/system/org/tree') orgOptions.value = res.data.map(item => ({ value: item.orgId, label: item.orgName })) } else { orgOptions.value = [] form.value.parentOrg = undefined } }) </script>

逻辑说明:watch监听userType,当值切换到admin时发起请求拉取机构树,映射成LaySelect需要的{ value, label }结构;切换回normal时清空选项和已选值。实际项目里这个请求可以通过类似 JS 技能函数封装进表单配置文件,做法是保持options字段的值为带参数的 Promise,最终由动态渲染组件内部消化。

5.2 laydate 日期范围组件的当前日期上限问题

热词里非常具体的一条是“layui date 最大日期当前日期”。这在原生 Layui 里是max配置项,但迁移到layui-vue的日期组件后,同样的问题需要换一个写法。

<template> <lay-date-picker v-model="form.dateRange" type="datetime" :range="true" :max="maxDate" placeholder="请选择开始时间 - 结束时间" /> </template> <script setup> import { ref } from 'vue' function getTodayEnd() { const now = new Date() const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0') const hours = String(now.getHours()).padStart(2, '0') const minutes = String(now.getMinutes()).padStart(2, '0') return `${year}-${month}-${day} ${hours}:${minutes}` } const maxDate = ref(getTodayEnd()) </script>

参数说明:max接收一个字符串,格式必须与type="datetime"对应的格式一致;这里取当前时刻的目的是阻止用户选择未来时间。边界场景是跨天操作,比如用户在 23:59 打开页面,组件初始化时max已固定,等到 00:00 后应该重新赋值一次。解决方式是在页面onMounted里再执行一次getTodayEnd(),或者监听系统时间变化——但后者并不必要,让用户刷新页面通常已经是可接受的兜底。

5.3 按钮级权限指令与表格列的事件释放

前面路由层处理了菜单权限,但按钮权限需要一个轻量手段:注册v-permission指令。

import { useUserStore } from '@/store/user' export const permission = { mounted(el, binding) { const userStore = useUserStore() const required = binding.value const hasPermission = userStore.permissions.includes(required) if (!hasPermission) { el.parentNode && el.parentNode.removeChild(el) } } }

然后在main.jsapp.directive('permission', permission),页面上使用<lay-button v-permission="'system:user:delete'">删除</lay-button>。这个指令的执行时机是组件挂载时,如果权限集合是异步加载的,需要确保按钮渲染晚于权限集合填充,通常做法是权限未加载完成前用v-if拦截整个页面渲染。表格操作列里的删除、编辑按钮同理,可以把整列放入v-if="hasPermission"的判断里,而不是逐个控制。

6. 交付前的两个细节:Excel 在线预览的处理和排错思路落地

通用后台管理系统里,“在线预览 excel 表格”在热词中出现的频率很高。实现方案常见有三种:xlsx解析后渲染成自定义表格、SheetJS配合导出、页面<iframe>嵌入微软 Office 在线预览服务。第三方服务依赖外网且对文件大小有上限,我一般选择xlsx解析方案,前端完整可控。

npm install xlsx
import * as XLSX from 'xlsx' function previewExcel(file) { const reader = new FileReader() reader.onload = (e) => { const data = new Uint8Array(e.target.result) const workbook = XLSX.read(data, { type: 'array' }) const firstSheetName = workbook.SheetNames[0] const worksheet = workbook.Sheets[firstSheetName] const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) // jsonData 是二维数组,第一行是表头,后续是数据行 previewData.value = jsonData } reader.readAsArrayBuffer(file) }

代码说明:sheet_to_jsonheader: 1参数表示输出为二维数组而不是对象数组,原因是 Excel 表头与数据字段名不一定一一对应,二维数组可以原样渲染成表格。拿到二维数组后,配合一个只读的LayTable就能把数据和表头动态渲染出来。注意xlsx这个包体积不小(约 800KB),建议用动态import()按需加载,避免首屏包体膨胀。

关于排错,列出两个最常遇到的坑。第一个是点击日期控件后日历面板位置偏移——这与父容器的transformoverflow属性有关,Layui 的position弹出基于document.body定位,当祖先元素创建了新的包含块时,浮层计算位置会失效,解决方式是给日期容器设置position: relative并确保浮层挂载到 body 下。第二个是 Sidebar 在刷新后里的激活菜单项丢失——这是因为menuLoaded动态注册后,el-menudefault-active需要读取当前路由meta.activeMenu,而不是本地存储里的上一个路径,否则点击刷新按钮后会高亮错误。

最后的验证方法很有价值:在路由守卫里加的next({ ...to, replace: true })会让刷新行为变成两次导航,用浏览器控制台观察 Network 面板,第一次导航会返回一个 404 或者空组件,第二次才真正渲染页面。如果看到两次请求都属于正常现象,说明动态路由注册生效了;如果第二次导航仍然匹配不到路由,优先检查后端返回的component字符串与views目录里实际组件路径是否完全一致。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询