简介:这是一套基于Vite5、Vue3、Ant Design Vue4与TypeScript5构建的后台管理系统基础模板,面向需要快速搭建权限中后台的前端开发者,适用于企业后台、管理平台及低代码场景的二次开发。项目采用组合式API与Hooks组织业务逻辑,内置RBAC权限控制、JSON Schema动态表单与动态表格方案,同时集成Vuex、Vue Router等全家桶实践,适合已掌握Vue基础、想深入工程化开发的读者进阶学习。压缩包共33个文件,体积仅147KB,内含TypeScript源码、Vite与ESLint等mjs构建校验配置、JSON依赖与工程配置、Docker与YAML容器部署文件、Markdown说明文档、TXT资源说明以及代码规范相关配置;其中mjs负责工程化脚本,json管理依赖,ts承载业务逻辑,整体目录划分清晰,便于按模块查阅,也便于快速定位所需内容。当前已有158人学习下载,资源附带完整的工程化工具链配置,包含代码风格检查、提交检查与多环境变量设置,并提供默认管理员账号,可直接运行查看效果。其中角色、菜单、按钮权限的控制逻辑,以及动态表单如何利用Schema配置自动生成校验规则与布局,均有直观代码示例可供拆解;对于想独立搭建后台的同学,可以重点研究路由守卫与权限指令的配合方式,以及动态表单配置如何映射为页面控件,这些实现都能直接借鉴,减少从零开发成本。整体轻量紧凑、模块耦合度低,既能帮助理解权限模型与动态渲染原理,也可作为脚手架参考和二次开发基底,迁移至真实业务系统中,适合初学者与进阶者参考。
1. 菜单、按钮、表单、表格,后台管理系统真正耗时间的四件事
后台管理系统的页面本身并不难,难在权限模型怎么设计才能撑住后续迭代,表单字段一多怎么不写重复代码,列表页字段一变怎么不跟着改页面。基于 vite5.x + vue3.x + ant-design-vue4.x + typescript hooks 这套技术栈做后台,算是当下工程化程度较高的一条路:Vite 负责开发体验,Vue3 组合式 API 负责逻辑组织,ant-design-vue 4.x 负责组件覆盖度,TypeScript 负责让配置和接口都不至于失控。这篇要讲的是这三件事的完整落地链路:RBAC 权限系统从路由到按钮的每一层拦截,JSON Schema 动态表单如何用一份配置递归渲染,动态表如何用一张配置表收敛掉所有查询页。适合正在做后台模板、或者被重复页面拖住的前端。
2. 基于 Vite5.x + Vue3.x + ant-design-vue4.x 搭一套 hooks 优先的工程骨架
2.1 初始化项目:create-vue 生成 Vue3 + TypeScript 底座
用官方脚手架初始化最稳,npm create vue@latest会引导选择 Vue Router、Pinia、TSX 等选项,生成的工程里 Vite5.x、Vue3.x、TypeScript 是配好的,不会出现版本打架。有个前提先确认:Vite5.x 要求 Node 18.0 及以上,低版本 Node 会在启动时报错或者直接 build 失败,装依赖前先node -v看一眼。
node -v # 确认 >= 18.0 npm create vue@latest交互式提问里,建议把 TypeScript、Vue Router、Pinia、TSX 都选上。TypeScript 管类型,Vue Router 是做 RBAC 动态路由的前提,Pinia 用来存用户信息和权限码,TSX 在动态表单的 render 函数、动态表格的自定义列里会用到。这几个选项一开,后续不需要再手动补依赖。
项目生成后第一件事是配 Vite 代理。后台项目基本都要接后端接口,直接在vite.config.ts里写 proxy,开发环境就没有跨域问题:
// vite.config.ts export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })这里的逻辑是:前端请求/api/user/list时,Vite 把请求转发到http://localhost:8080/user/list,前缀/api被 rewrite 规则去掉。实际项目中后端网关路径可能不同,改 target 和 rewrite 就行,前端 axios 请求代码无需跟着环境切换。
2.2 ant-design-vue4.x 全量引入还是按需引入
后台系统组件用得全,我一般入口全量引入。4.x 版本用app.use(Antd)一次性注册所有组件和指令,代码最少,打包体积偏大,但后台项目通常不追求首屏极限优化,换来的是开发效率和少踩坑。如果在意包体积,再换成 unplugin-vue-components 按需引入,ant-design-vue 官方文档有配套示例。
// src/main.ts import { createApp } from 'vue' import Antd from 'ant-design-vue' import 'ant-design-vue/dist/reset.css' import App from './App.vue' import router from './router' import pinia from './stores' const app = createApp(App) app.use(pinia) app.use(router) app.use(Antd) app.mount('#app')这里有个 4.x 迁移时容易踩的差别:样式入口是reset.css,不是旧版的antd.css。另外 4.x 的主题色统一走ConfigProvider的theme属性,全局改主色在 App.vue 顶层包一层即可:
<ConfigProvider :theme="{ token: { colorPrimary: '#1677ff' } }"> <RouterView /> </ConfigProvider>2.3 目录结构:types、hooks、components 的分层
这套骨架能不能长期维护,目录结构比具体写法更关键。我的原则是:组件只做渲染,业务逻辑全部抽到 hooks,接口调用只出现在api/目录。这样换后端不改页面,换页面不改逻辑。
| 目录/文件 | 职责 | 依赖 |
|---|---|---|
| api/ | 按模块拆分的接口请求 | 仅 axios 实例 |
| components/ | 通用组件 DynamicForm、DynamicTable | 仅 props + slots |
| hooks/ | useAuth、useTable、useFormSchema | api/ 与 stores/ |
| layout/ | 主框架布局、侧边菜单 | 路由表 |
| router/ | 静态路由与动态路由注册 | stores/ |
| stores/ | Pinia:用户信息、权限码、菜单 | api/ |
| types/ | 全局类型、接口返回结构 | 无 |
约定是单向依赖:页面组件可以依赖 hooks,hooks 不能反向依赖页面;types/里定义的接口类型被 api、stores、hooks 共用。这样权限、表单、表格三块能力可以独立拆出去给别的项目复用。
2.4 一个 useTable Hook:TypeScript 泛型的第一次实践
数据请求、分页参数、加载状态这组逻辑在后台系统里重复率最高,值得用泛型封装一次。泛型的意义在于:调用方传入什么行类型,拿回来的rows就是什么类型,不用到处as any。
// src/hooks/useTable.ts import { ref, reactive } from 'vue' export interface PageParams { pageNum: number pageSize: number [key: string]: unknown } export function useTable<T>( fetcher: (params: PageParams) => Promise<{ rows: T[]; total: number }> ) { const loading = ref(false) const rows = ref<T[]>([]) const total = ref(0) const params = reactive<PageParams>({ pageNum: 1, pageSize: 10 }) async function load() { loading.value = true try { const res = await fetcher({ ...params }) rows.value = res.rows total.value = res.total } finally { loading.value = false } } function search(values: Record<string, unknown>) { Object.assign(params, values, { pageNum: 1 }) load() } return { loading, rows, params, total, load, search } }说明几个参数和边界:fetcher接收分页参数并返回固定结构{ rows, total },如果后端返回结构不同,在 api 层做一层映射;search里重置pageNum为 1 是必须的,否则在第三页搜索会搜出空列表;返回的params是响应式对象,可以直接绑定到分页组件的v-model:current和v-model:pageSize。这个 Hook 在动态表格章节会直接复用。
3. RBAC 权限系统落地:路由守卫、动态菜单与按钮级权限
3.1 RBAC 三要素落到前端:用户、角色、权限码的投影
RBAC(基于角色的访问控制)在后台系统里的前端投影是:用户登录后,后端返回该用户拥有的角色列表、菜单树、权限码数组。前端拿权限码控制路由能否访问、菜单是否渲染、按钮是否显示。这里必须强调:前端做的是体验和路由层面拦截,真正防越权的校验在后端接口上,前端权限码不能作为安全边界。
登录接口的返回结构通常长这样:
interface LoginResult { token: string user: { id: number name: string roles: string[] permissionCodes: string[] // 例如 ['user:create', 'user:update'] menus: MenuNode[] // 菜单树,叶子节点挂组件路径 } }permissionCodes是扁平的权限码数组,后端把所有权限点拼好一次性返回,前端只需要做includes判断。菜单树单独返回的原因是:动态路由需要层级结构,权限码只需要判断存在性,两件事的数据结构不一样,不要硬塞在同一棵树上。
3.2 动态路由注册:router.addRoute 与 beforeEach 的组合
动态路由的流程是:用户登录后拉取菜单树,把菜单树转换成 vue-router 的路由表,用router.addRoute逐条注册,之后每次导航通过守卫放行。核心代码在守卫里:
// src/router/guard.ts import router from './index' import { useUserStore } from '@/stores/user' const staticRoutes: RouteRecordRaw[] = [ { path: '/login', name: 'Login', component: () => import('@/views/login/index.vue') }, { path: '/', name: 'Layout', component: () => import('@/layout/index.vue') } ] let dynamicRoutesAdded = false router.beforeEach(async (to) => { const userStore = useUserStore() if (!userStore.token) { return to.path === '/login' ? true : '/login' } if (to.path === '/login') return '/' if (!dynamicRoutesAdded) { const menus = await userStore.fetchMenus() const routes = transformMenusToRoutes(menus) routes.forEach((route) => router.addRoute(route)) dynamicRoutesAdded = true return { ...to, replace: true } } return true })dynamicRoutesAdded标志位很关键,避免刷新后重复注册路由导致警告或报错。transformMenusToRoutes把后端菜单节点映射成RouteRecordRaw,做三件事:把component字符串映射为实际的() => import()函数、把父级菜单设为 Layout 的子路由、把没有匹配权限的菜单直接过滤掉。最后return { ...to, replace: true }是为了在 addRoute 生效后重新走一次导航,否则首次访问动态路由会命中 404。
注意:使用了动态路由后,不要再用静态路由表里的通配符/:pathMatch(.*)*提前兜底,否则低权限用户访问未授权页面会被 404 吞掉,拦截逻辑会失效。
3.3 按钮权限的 v-permission 指令与 useAuth Hook
路由控制的是页面级别,按钮级权限需要单独处理。按钮权限我不建议散落在业务代码里用v-if="userStore.hasPermission('user:create')",维护性太差。统一封装一个指令:
// src/directives/permission.ts import type { Directive } from 'vue' import { useUserStore } from '@/stores/user' export const permission: Directive<HTMLElement, string> = { mounted(el, binding) { const userStore = useUserStore() if (!userStore.permissionCodes.includes(binding.value)) { el.parentNode?.removeChild(el) } } }用法是<Button v-permission="'user:create'">新增用户</Button>。指令在元素挂载时执行一次,没有权限直接移除 DOM 节点。权限码建议用模块:动作的命名约定,和后端接口路径对应,找问题的时候能顺着权限码直接定位接口。
指令有个局限:如果权限码是异步加载的,而按钮先渲染了,指令执行时权限码还没到位,会把有权限的按钮也删掉。这时候两个解法:一是页面在拉权限前用v-if控制整块区域不渲染;二是在指令里监听 store 变化重新判断。后台场景多数用户信息在登录后立即返回,第一种够用。
| 权限码 | 资源 | 校验位置 |
|---|---|---|
| user:list | 用户列表 | 路由/菜单 |
| user:create | 新增用户按钮 | 按钮指令 |
| user:update | 编辑用户按钮 | 按钮指令 |
| role:assign | 分配角色按钮 | 按钮指令 |
3.4 权限设计里最常见的 3 个坑
第一个坑是刷新后动态路由丢失。解决方案就是上文dynamicRoutesAdded标志位,但注意刷新后标志位会重置,所以守卫里要按token存在且标志位为 false 的顺序去拉取菜单。第二个坑是按钮权限码散落在页面各处,权限调整的时候全局搜字符串。解决方式是把权限码常量统一放在constants/permission.ts里,模板和代码都引用常量而不是裸字符串。第三个坑是后端直接返回组件路径字符串,前端需要维护一份路径到() => import()的映射表,漏一个组件就白屏一次。这个映射表可以在transformMenusToRoutes中统一维护,千万不要试图用动态import()变量路径,Vite 打包时静态分析会直接失败。
4. JSON Schema 动态表单:用一份配置渲染整个表单
4.1 动态表单的数据结构:字段、组件、校验与联动分离
JSON Schema 动态表单的核心思想是把表单的“描述”和“渲染”分开:描述是纯数据,渲染是同一套组件递归完成。每次新增表单不是新建页面,而是新增一份配置。字段结构可以这样定义:
// src/components/DynamicForm/types.ts export interface FormSchema { field: string // 字段名,对应 model 的 key title: string // 标签文本 component: 'input' | 'select' | 'datePicker' | 'switch' | 'number' | 'textarea' props?: Record<string, unknown> // 透传给组件的属性 required?: boolean rules?: FormItemRule[] // 直接复用 ant-design-vue 校验规则 visibleWhen?: { field: string value: unknown // 等于某个值时显示 } }把字段名、组件类型、校验规则、联动条件分开,而不是混在一个大对象里,是为了让渲染组件足够简单:它只按component找组件、按props传参、按rule校验。后端只要返回这种结构的 JSON,前端就能自动渲染。
4.2 一个携带校验和联动的动态表单组件
组件实现的关键是遍历 schema 渲染表单项,并让visibleWhen生效:
<!-- src/components/DynamicForm/index.vue --> <script setup lang="ts"> import { reactive, computed } from 'vue' import type { FormSchema } from './types' const props = defineProps<{ schema: FormSchema[] }>() const model = reactive<Record<string, any>>({}) const visibleFields = computed(() => props.schema.filter((item) => { if (!item.visibleWhen) return true const { field, value } = item.visibleWhen return model[field] === value }) ) </script> <template> <a-form :model="model" layout="vertical"> <a-form-item v-for="item in visibleFields" :key="item.field" :label="item.title" :required="item.required" :rules="item.rules" > <a-input v-if="item.component === 'input'" v-model:value="model[item.field]" v-bind="item.props" /> <a-select v-else-if="item.component === 'select'" v-model:value="model[item.field]" v-bind="item.props" /> <a-switch v-else-if="item.component === 'switch'" v-model:checked="model[item.field]" /> </a-form-item> </a-form> </template>这里model用reactive包裹,表单字段值天然是响应式的。visibleFields是计算属性,当关联字段值变化时,显隐联动自动触发。v-bind="item.props"把配置里的placeholder、options等属性直接透传给 ant-design-vue 组件,不用在渲染层做二次映射。
组件数量多的时候,v-if / v-else-if会变得很长。更合适的做法是维护一张组件映射表,把字符串组件名映射到实际组件对象,用<component :is="componentMap[item.component]" />渲染。上面的写法保留 if 链是为了直观。
4.3 字段映射、校验规则与联动配置的完整示例
| schema.component | ant-design-vue 组件 | 数据格式 |
|---|---|---|
| input | a-input | string |
| textarea | a-textarea | string |
| number | a-input-number | number |
| select | a-select | string / number / string[] |
| datePicker | a-date-picker | dayjs 对象 |
| switch | a-switch | boolean |
一份实际可运行的 schema 配置长这样:
const userFormSchema: FormSchema[] = [ { field: 'name', title: '姓名', component: 'input', required: true, rules: [{ required: true, message: '请输入姓名' }] }, { field: 'accountType', title: '账号类型', component: 'select', required: true, props: { options: [ { label: '管理员', value: 'admin' }, { label: '普通用户', value: 'user' } ] } }, { field: 'expireDate', title: '过期时间', component: 'datePicker', props: { style: { width: '100%' } }, visibleWhen: { field: 'accountType', value: 'admin' } } ]当账号类型切换到“管理员”时,过期时间字段出现;切回“普通用户”时自动消失。规则直接复用 ant-design-vue 的rules格式,校验触发方式、错误信息展示都不需要额外代码。
这里有个常见误用:为了追求“完全动态”,把校验规则也全部交给后端下发字符串。实际上混合模式更合理,必填、长度、格式这类通用规则后端下发;复杂业务校验前端写函数注册进规则库。否则后端改一个正则,前端还得发版。
5. 动态表格与接口字段映射,用一张配置表收敛查询页
5.1 动态表的三要素:columns、dataMap 与 api
查询页是后台系统里数量最多的页面,列表列配置、搜索表单、分页逻辑三件事高度相似。动态表的设计思路是:列配置由后端返回或由前端配置表驱动,表格组件只负责渲染。对接后端时,后端返回的字段名经常和前端展示不一致,dataMap 解决这个问题:
// src/hooks/useDynamicTable.ts import { useTable } from './useTable' export interface ColumnConfig<T> { title: string dataIndex: keyof T // 数据字段 width?: number dataMap?: (row: T) => string // 字段格式化 } export function useDynamicTable<T>( fetcher: (params: PageParams) => Promise<{ rows: T[]; total: number }>, columns: ColumnConfig<T>[] ) { const table = useTable<T>(fetcher) return { ...table, columns } }后端返回status: 1,前端需要展示“启用”,dataMap里做一元映射;后端返回时间戳,前端需要展示格式化日期,也在dataMap里处理。列宽、对齐、固定列这类展示属性放配置里,渲染组件用v-bind透传。
5.2 操作列与 RBAC 权限码联动
动态表格的每行操作列(编辑、删除、分配角色)也走权限判断,不能渲染出来再拦截。操作按钮配置上加权限码字段:
<!-- src/components/DynamicTable/index.vue --> <script setup lang="ts"> import { useUserStore } from '@/stores/user' defineProps<{ columns: any[] rows: Record<string, any>[] actions?: { label: string permission: string onClick: (row: Record<string, any>) => void }[] }>() const userStore = useUserStore() </script> <template> <a-table :columns="columns" :data-source="rows" row-key="id"> <template #bodyCell="{ column, record }"> <template v-if="column.key === 'action'"> <a-button v-for="action in actions" :key="action.label" type="link" size="small" v-if="userStore.permissionCodes.includes(action.permission)" @click="action.onClick(record)" > {{ action.label }} </a-button> </template> </template> </a-table> </template>操作列按钮的显隐完全由userStore.permissionCodes驱动。后端返回的菜单只控制页面入口,页面内部的操作按钮靠权限码控制,两层配合才是完整的 RBAC 前端实现。
5.3 交付前的验证清单:权限、表单、表格逐个过
| 检查项 | 操作 | 预期 |
|---|---|---|
| 路由刷新恢复 | 登录后进入子页面,按 F5 刷新 | 停留在原页面,不回登录页 |
| 无权限页面拦截 | 使用低权限账号访问未授权路由 | 被守卫重定向到 403 或首页 |
| 按钮级权限 | 对比高/低权限账号的同个页面 | 按钮显隐与账号权限码一致 |
| 表单联动 | 切换 visibleWhen 关联字段 | 关联字段出现/消失,值被清空 |
| 动态表字段映射 | 造一条后端异常数据 | dataMap 不抛错,展示兜底文案 |
最后提一个动态表性能注意点:列配置如果由后端返回,每次渲染都会触发一次额外请求,建议在用户信息拉取时连同列配置一起缓存到 Pinia,而不是每次进页面重新请求。表格本身的数据走 useTable 分页拉取,列配置走缓存,两件事互不干扰。
本文还有配套的精品资源,点击获取