
简介这是一套基于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 vuelatest会引导选择 Vue Router、Pinia、TSX 等选项生成的工程里 Vite5.x、Vue3.x、TypeScript 是配好的不会出现版本打架。有个前提先确认Vite5.x 要求 Node 18.0 及以上低版本 Node 会在启动时报错或者直接 build 失败装依赖前先node -v看一眼。node -v # 确认 18.0 npm create vuelatest交互式提问里建议把 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 / /ConfigProvider2.3 目录结构types、hooks、components 的分层这套骨架能不能长期维护目录结构比具体写法更关键。我的原则是组件只做渲染业务逻辑全部抽到 hooks接口调用只出现在api/目录。这样换后端不改页面换页面不改逻辑。目录/文件职责依赖api/按模块拆分的接口请求仅 axios 实例components/通用组件 DynamicForm、DynamicTable仅 props slotshooks/useAuth、useTable、useFormSchemaapi/ 与 stores/layout/主框架布局、侧边菜单路由表router/静态路由与动态路由注册stores/stores/Pinia用户信息、权限码、菜单api/types/全局类型、接口返回结构无约定是单向依赖页面组件可以依赖 hookshooks 不能反向依赖页面types/里定义的接口类型被 api、stores、hooks 共用。这样权限、表单、表格三块能力可以独立拆出去给别的项目复用。2.4 一个 useTable HookTypeScript 泛型的第一次实践数据请求、分页参数、加载状态这组逻辑在后台系统里重复率最高值得用泛型封装一次。泛型的意义在于调用方传入什么行类型拿回来的rows就是什么类型不用到处as any。// src/hooks/useTable.ts import { ref, reactive } from vue export interface PageParams { pageNum: number pageSize: number [key: string]: unknown } export function useTableT( fetcher: (params: PageParams) Promise{ rows: T[]; total: number } ) { const loading ref(false) const rows refT[]([]) const total ref(0) const params reactivePageParams({ 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: Recordstring, 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-ifuserStore.hasPermission(user:create)维护性太差。统一封装一个指令// src/directives/permission.ts import type { Directive } from vue import { useUserStore } from /stores/user export const permission: DirectiveHTMLElement, string { mounted(el, binding) { const userStore useUserStore() if (!userStore.permissionCodes.includes(binding.value)) { el.parentNode?.removeChild(el) } } }用法是Button v-permissionuser: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?: Recordstring, 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 langts import { reactive, computed } from vue import type { FormSchema } from ./types const props defineProps{ schema: FormSchema[] }() const model reactiveRecordstring, 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 :modelmodel layoutvertical a-form-item v-foritem in visibleFields :keyitem.field :labelitem.title :requireditem.required :rulesitem.rules a-input v-ifitem.component input v-model:valuemodel[item.field] v-binditem.props / a-select v-else-ifitem.component select v-model:valuemodel[item.field] v-binditem.props / a-switch v-else-ifitem.component switch v-model:checkedmodel[item.field] / /a-form-item /a-form /template这里model用reactive包裹表单字段值天然是响应式的。visibleFields是计算属性当关联字段值变化时显隐联动自动触发。v-binditem.props把配置里的placeholder、options等属性直接透传给 ant-design-vue 组件不用在渲染层做二次映射。组件数量多的时候v-if / v-else-if会变得很长。更合适的做法是维护一张组件映射表把字符串组件名映射到实际组件对象用component :iscomponentMap[item.component] /渲染。上面的写法保留 if 链是为了直观。4.3 字段映射、校验规则与联动配置的完整示例schema.componentant-design-vue 组件数据格式inputa-inputstringtextareaa-textareastringnumbera-input-numbernumberselecta-selectstring / number / string[]datePickera-date-pickerdayjs 对象switcha-switchboolean一份实际可运行的 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 ColumnConfigT { title: string dataIndex: keyof T // 数据字段 width?: number dataMap?: (row: T) string // 字段格式化 } export function useDynamicTableT( fetcher: (params: PageParams) Promise{ rows: T[]; total: number }, columns: ColumnConfigT[] ) { const table useTableT(fetcher) return { ...table, columns } }后端返回status: 1前端需要展示“启用”dataMap里做一元映射后端返回时间戳前端需要展示格式化日期也在dataMap里处理。列宽、对齐、固定列这类展示属性放配置里渲染组件用v-bind透传。5.2 操作列与 RBAC 权限码联动动态表格的每行操作列编辑、删除、分配角色也走权限判断不能渲染出来再拦截。操作按钮配置上加权限码字段!-- src/components/DynamicTable/index.vue -- script setup langts import { useUserStore } from /stores/user defineProps{ columns: any[] rows: Recordstring, any[] actions?: { label: string permission: string onClick: (row: Recordstring, any) void }[] }() const userStore useUserStore() /script template a-table :columnscolumns :data-sourcerows row-keyid template #bodyCell{ column, record } template v-ifcolumn.key action a-button v-foraction in actions :keyaction.label typelink sizesmall v-ifuserStore.permissionCodes.includes(action.permission) clickaction.onClick(record) {{ action.label }} /a-button /template /template /a-table /template操作列按钮的显隐完全由userStore.permissionCodes驱动。后端返回的菜单只控制页面入口页面内部的操作按钮靠权限码控制两层配合才是完整的 RBAC 前端实现。5.3 交付前的验证清单权限、表单、表格逐个过检查项操作预期路由刷新恢复登录后进入子页面按 F5 刷新停留在原页面不回登录页无权限页面拦截使用低权限账号访问未授权路由被守卫重定向到 403 或首页按钮级权限对比高/低权限账号的同个页面按钮显隐与账号权限码一致表单联动切换 visibleWhen 关联字段关联字段出现/消失值被清空动态表字段映射造一条后端异常数据dataMap 不抛错展示兜底文案最后提一个动态表性能注意点列配置如果由后端返回每次渲染都会触发一次额外请求建议在用户信息拉取时连同列配置一起缓存到 Pinia而不是每次进页面重新请求。表格本身的数据走 useTable 分页拉取列配置走缓存两件事互不干扰。本文还有配套的精品资源点击获取