
简介这份后台管理系统项目源码面向具备一定 Vue 基础、希望系统实践 Vue3 技术栈的前端学习者与开发者围绕现代管理后台的常见需求整合 Vue3、Vite、TypeScript、Ant Design Vue 与 Pinia 等主流方案。压缩包共 172 个文件约 232KB包含 74 个 vue 单文件组件、63 个 ts 脚本、9 个 json 配置以及 less 样式、png 图标和项目环境配置等基本覆盖页面、逻辑、样式与构建配置各层。已有 1196 人学习下载适合作为入门练手或企业级项目模板参考。项目中可重点了解 Composition API 的代码组织方式、TypeScript 与组件状态结合时的类型约束、Pinia 按领域拆分 store 的做法以及 Keep-Alive 页面缓存和基于文件结构自动生成路由的实现思路便于读者快速理解后台系统的模块划分与开发流程。1. 一个后台管理项目该有的样子从能跑到能改的距离后台管理系统是前端工程里最容易被低估的活儿。很多人接到需求第一反应是找个模板套一套表格、表单、弹窗拼起来就算完事。真到接入真实业务权限要按角色动态下发、菜单要从后端配置、页面要按需加载、主题要能切换这时候才发现当初套的模板里到处是写死的路由表、随手塞进组件的请求逻辑。基于 Vue3 与 Vite 搭建的这套后台管理系统源码正好卡在能跑起来和能改得动这两个状态之间它把路由、状态、权限、请求、组件封装这几块拆得比较清楚适合已经会写 Vue 但没独立搭过中大型后台的前端拿来对照也适合团队里想统一脚手架规范的开发者做二次裁剪。下面不按功能清单罗列而是顺着一个请求从进页面到落到表格里的链路把每一层拆开讲。2. 技术选型与目录结构这套栈解决的是什么问题2.1 为什么是 Vue3 Vite TypeScript 这套组合Vue3 的 Composition API 在这类项目里最大的价值不是新语法而是把原本散落在 data、methods、computed 里的逻辑按业务聚合成一块权限判断、表格分页、表单校验各自成函数复用时不用再靠 mixin 那种隐式合并。Vite 换来的是冷启动和热更新速度后台项目模块多、路由多Webpack 时代改一行代码等三五秒的体验很难忍Vite 的按需编译把这块痛点抹平了。TypeScript 在后台系统里的性价比尤其高因为后台的核心是数据形状——接口返回的字段、表格列的配置、表单的校验规则一旦类型定死改接口时编译器直接告诉你哪几个页面要跟着改这是纯 JS 项目做不到的后悔药。Ant Design Vue 提供的是成体系的中后台组件表格的虚拟滚动、表单的动态校验、弹窗的受控逻辑都经过大量场景打磨自己从零写等于重复造轮子。Pinia 取代 Vuex 的理由也很实际它没有 mutation 那层壳模块天然分文件TypeScript 推导更顺store 里写异步请求不用再绕 action。常见做法是这五样各管一摊Vite 管构建与开发服务Vue3 管视图与组合逻辑TypeScript 管类型约束Ant Design Vue 管组件层Pinia 管跨组件共享状态。剩下没写进标题但后台必备的一般是 axios 封装请求、vue-router 管路由与守卫、按角色控制菜单与按钮权限。2.2 目录结构拆解这类项目跑起来后src下的分层通常长这样我按职责说明每一层该放什么src/ ├── api/ # 接口定义按业务模块拆文件 │ ├── request.ts # axios 实例、拦截器、统一错误处理 │ └── user.ts # 用户相关接口函数返回 Promise ├── assets/ # 静态资源图标、图片 ├── components/ # 全局通用组件如分页表格、上传封装 ├── layouts/ # 布局层侧边栏、顶栏、面包屑 ├── router/ # 路由表 动态路由生成 守卫 ├── stores/ # Pinia 模块user / app / permission ├── styles/ # 全局样式、变量、主题覆盖 ├── utils/ # 工具函数token 存取、日期格式化 ├── views/ # 页面级组件按业务目录分组 ├── App.vue └── main.ts关键在api和router这两层的边界。api只负责请求怎么发、返回什么类型不掺业务判断router负责谁能进哪个页面两者的交叉点靠stores/permission.ts做中介。把这三者搅在一起是新手最常见的翻车点页面里直接用axios.get拼 URL权限判断写在组件onMounted里最后想加个统一 loading 或者统一错误提示得改几十个文件。2.3 别名与构建配置vite.config.ts里除了常规插件后台项目一定会配路径别名和代理import { defineConfig } from vite import vue from vitejs/plugin-vue import { resolve } from path export default defineConfig({ plugins: [vue()], resolve: { alias: { // 用 指向 src避免 ../../../ 这种相对路径地狱 : resolve(__dirname, src) } }, server: { port: 3000, proxy: { // 开发环境代理解决跨域生产环境走 nginx 转发 /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })alias里的要在tsconfig.json的paths里同步一份否则 TS 类型解析会报找不到模块这是很多人第一次配 TS 项目时的玄学卡点。proxy的rewrite看后端接口是否自带/api前缀带了就删掉这行不带就保留配错的表现是开发环境 404、生产环境正常非常迷惑。3. 请求层与状态管理一条数据从接口到表格的链路3.1 axios 实例封装后台项目的请求层必须收敛到一个实例上才可能做统一的 token 注入、错误码处理、重复请求取消。典型的api/request.tsimport axios, { AxiosInstance, AxiosResponse } from axios import { message } from ant-design-vue import { useUserStore } from /stores/user const service: AxiosInstance axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) // 请求拦截统一带上 token service.interceptors.request.use( (config) { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }, (error) Promise.reject(error) ) // 响应拦截统一处理业务码与异常 service.interceptors.response.use( (response: AxiosResponse) { const { code, data, msg } response.data if (code 200) return data // 401 时清理登录态并跳登录页避免用户卡在空白页 if (code 401) { useUserStore().logout() window.location.href /login return Promise.reject(new Error(msg)) } message.error(msg || 请求失败) return Promise.reject(new Error(msg)) }, (error) { message.error(error.message || 网络异常) return Promise.reject(error) } ) export default service这段代码有两个容易忽略的点。其一useUserStore()必须在拦截器函数内部调用不能提到模块顶层因为 Pinia 实例在main.ts里才挂载顶层调用会拿到 undefined。其二响应拦截里直接return data意味着业务代码拿到的已经是解包后的数据类型声明要跟着写成service.getT返回PromiseT否则 TS 会认为返回的是AxiosResponse到处报错。参数说明baseURL走环境变量.env.development和.env.production各写一份Vite 只注入VITE_前缀的变量timeout设 10 秒后台报表类接口可能更久这类接口单独传timeout: 60000覆盖。3.2 接口模块的写法在api层里一个业务文件只导出函数不导出实例import request from ./request export interface UserItem { id: number username: string role: string status: 0 | 1 } export interface PageResultT { list: T[] total: number } // 分页查询参数全部显式声明方便调用方对齐 export function getUserList(params: { page: number size: number keyword?: string }) { return request.getPageResultUserItem(/user/list, { params }) } export function updateUserStatus(id: number, status: 0 | 1) { return request.post(/user/status, { id, status }) }PageResultT这种泛型分页结构建议全项目共用一份放在types里所有列表接口复用避免每个页面都手写一遍list和total。status用0 | 1字面量联合类型而不是number调用方传错值时立刻报错这就是 TypeScript 在后台项目里最实在的收益。3.3 Pinia 的分层user / app / permission状态管理不是把所有东西都塞进 store。我的分法是user存 token、用户信息、角色app存侧边栏折叠状态、主题、语言这类 UI 偏好permission存动态路由表和菜单树。以user为例import { defineStore } from pinia import { ref } from vue import { login as loginApi, getProfile } from /api/user export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(token) || ) const roles refstring[]([]) const userInfo refRecordstring, any({}) async function login(form: { username: string; password: string }) { const data await loginApi(form) token.value data.token // 持久化刷新页面后守卫还有得判断 localStorage.setItem(token, data.token) } async function fetchProfile() { const data await getProfile() roles.value data.roles userInfo.value data } function logout() { token.value roles.value [] localStorage.removeItem(token) } return { token, roles, userInfo, login, fetchProfile, logout } })用 setup 写法而不是 options 写法好处是ref和普通函数混写没有心智负担this的问题也消失了。token 落 localStorage 是常规做法代价是 XSS 风险更稳的方案是 httpOnly cookie但那样跨域和 CSRF 又要额外处理后台系统内网部署时一般权衡后选前者。fetchProfile的调用时机很关键放在路由守卫里、进主布局之前执行一次保证拿到角色后再生成动态路由。放错位置的典型后果是刷新页面后菜单闪一下变空因为守卫放行时角色还没拉回来。3.4 动态路由与权限守卫后台的权限分两层路由级能不能进这个页面和按钮级能不能点这个按钮。路由级靠router.beforeEach加动态addRouterouter.beforeEach(async (to, from, next) { const userStore useUserStore() const permissionStore usePermissionStore() if (!userStore.token) { // 未登录放行登录页其余打回登录 return to.path /login ? next() : next(/login?redirect${to.path}) } if (to.path /login) return next(/) // 已登录但还没生成路由说明是刷新或首次进入 if (!permissionStore.routes.length) { try { await userStore.fetchProfile() const routes await permissionStore.generateRoutes(userStore.roles) routes.forEach((r) router.addRoute(r)) // replace 而非 push避免历史栈里留下一条无效记录 return next({ ...to, replace: true }) } catch (e) { userStore.logout() return next(/login) } } next() })next({ ...to, replace: true })这句是整套动态路由的核心。addRoute之后当前这次导航匹配的还是旧路由表直接next()会白屏或 404必须重新触发一次导航让新路由表生效。这个坑几乎每个手写动态路由的人都踩过表现是登录成功后页面空白、控制台无报错。按钮级权限一般封装成指令// v-permission[user:delete] app.directive(permission, { mounted(el, binding) { const userStore useUserStore() const need binding.value as string[] const has need.some((p) userStore.permissions.includes(p)) if (!has) el.parentNode?.removeChild(el) } })注意这里是移除 DOM 而不是display: none因为隐藏元素仍能被改样式点出来权限校验必须在服务端再做一遍前端这层只是体验优化。4. 页面层实战列表页、表单页与布局壳4.1 列表页的通用封装后台里 70% 的页面是搜索条件 表格 分页。与其每个页面复制一遍不如封一个组合式函数import { ref, reactive } from vue import type { PageResult } from /types export function useTableT( fetcher: (params: any) PromisePageResultT, initQuery: Recordstring, any {} ) { const list refT[]([]) const loading ref(false) const total ref(0) const query reactive({ page: 1, size: 10, ...initQuery }) async function load() { loading.value true try { const res await fetcher({ ...query }) list.value res.list total.value res.total } finally { // finally 里关 loading请求失败也不会卡住转圈 loading.value false } } function search() { query.page 1 load() } function reset() { Object.keys(query).forEach((k) { if (![page, size].includes(k)) delete (query as any)[k] }) Object.assign(query, initQuery) search() } return { list, loading, total, query, load, search, reset } }参数说明fetcher传接口函数initQuery传搜索条件的初始值。reset里先删再合并是为了处理用户填了初始值里没有的字段这种情况直接Object.assign会留下脏数据重置后查询条件还在这个血泪经验值得记一笔。页面里用起来就三行const { list, loading, total, query, load, search, reset } useTable( getUserList, { keyword: } ) onMounted(load)表格用 Ant Design Vue 的a-tablerow-key必须给否则展开行、选中行在数据刷新后状态错乱pagination用受控模式绑query.page和query.size别用组件内部维护的分页否则搜索重置时分页对不上。4.2 表单页的校验与提交表单页的痛点在编辑回显 校验 提交后返回列表。a-form的ref拿到的实例上validate()返回 Promise配合async/await写起来很顺const formRef ref() const form reactive({ username: , role: , status: 1 }) const rules { username: [{ required: true, message: 请输入用户名, trigger: blur }], role: [{ required: true, message: 请选择角色, trigger: change }] } async function submit() { try { await formRef.value.validate() await saveUser({ ...form }) message.success(保存成功) router.back() } catch (e) { // 校验失败时 validate 会 reject这里静默处理 console.warn(表单校验未通过, e) } }注意validate()失败会抛异常如果不try/catch控制台会出现未捕获的 Promise 拒绝虽然不影响功能但很脏。编辑页回显要在onMounted里根据路由参数拉详情别直接从列表页query传整个对象过去刷新就丢了这是很常见的偷懒写法。4.3 布局壳与菜单渲染layouts层负责侧边栏、顶栏、面包屑、内容区的骨架。菜单树从permissionstore 里取渲染时用a-menu递归template a-menu v-model:selectedKeysselectedKeys modeinline clickonMenuClick template v-foritem in menus :keyitem.path a-sub-menu v-ifitem.children?.length :keyitem.path template #title{{ item.title }}/template a-menu-item v-forchild in item.children :keychild.path {{ child.title }} /a-menu-item /a-sub-menu a-menu-item v-else :keyitem.path{{ item.title }}/a-menu-item /template /a-menu /templateselectedKeys要跟当前路由同步一般在watch(() route.path)里赋值否则刷新页面后菜单高亮会丢。面包屑从route.matched里取matched记录的是路由匹配链比手动维护一份映射表可靠得多。5. 避坑与排查这几处翻车我见得最多5.1 刷新后动态路由丢失、页面 404现象登录后一切正常手动刷新当前页直接跳到 404 或者白屏。 原因动态路由是运行时addRoute加的刷新后内存里的路由表清空守卫还没重建就放行了导航。 解决在守卫里判断permissionStore.routes.length 0时先拉角色、生成路由、addRoute然后next({ ...to, replace: true })重新导航。同时把生成路由的函数做成纯函数从角色到路由表的映射不依赖组件状态方便排查。5.2 Pinia 在拦截器里拿到 undefined现象api/request.ts里调用useUserStore()报 getActivePinia was called with no active Pinia。 原因在模块顶层执行了 store 调用此时app.use(pinia)还没执行。 解决把useUserStore()挪进拦截器回调内部或者在main.ts里先app.use(pinia)再引入使用请求层的模块。Vite 的模块加载顺序和 Webpack 有差异这类循环依赖问题在迁移时特别容易冒出来。5.3 环境变量在代码里读出来是 undefined现象import.meta.env.VITE_API_BASE_URL打印为 undefined请求全打到当前域名。 原因.env文件里变量名没加VITE_前缀或者文件放在项目根目录之外或者改完.env没重启 dev server。 解决变量统一VITE_开头文件放根目录改完重启。生产构建时确认 CI 里注入了对应环境变量别只在本地.env.production里写。5.4 表格分页搜索后页码错乱现象在第 3 页点搜索结果为空但总数明明有数据。 原因搜索时没把page重置为 1接口按第 3 页去查数据不足自然为空。 解决搜索函数里先query.page 1再请求重置按钮同理。分页组件用受控模式current绑query.page别让组件自己维护。5.5 打包后体积过大、首屏白屏久现象dist里单个 chunk 超过 2MB首屏等好几秒。 原因Ant Design Vue 全量引入图表库、编辑器等重型依赖也打进主包。 解决组件按需引入配unplugin-vue-components路由级页面用() import()懒加载重型库单独manualChunks拆出去构建配置里开build.chunkSizeWarningLimit观察拆分效果别一刀切。6. 二次开发从套模板到改成自己的项目拿到这套源码之后我一般不急着写业务页面而是先做三件事顺序不能反。第一件是换掉身份层。登录接口、token 字段名、用户信息结构改成自己后端的形状stores/user.ts和api/user.ts一起动改完用真实接口跑一遍登录到首页的完整链路。这一步不通过后面所有页面都是空中楼阁。第二件是定路由与权限的映射规则。后端返回的角色标识和前端路由的meta.roles要对齐建议在permission.ts里写一份显式映射表而不是靠字符串拼接猜。下面是改造时的对照表我习惯先填这张表再动代码改造点原实现位置需要同步修改的地方接口前缀request.ts的 baseURL.env.*文件、nginx 转发规则登录字段api/user.ts的 loginstores/user.ts、登录页表单角色标识路由meta.roles后端角色字典、指令权限码菜单结构permission.ts生成逻辑侧边栏递归组件、面包屑主题色styles变量Ant Design Vue 的 ConfigProvider第三件是裁组件。模板里带的富文本、图表、导入导出这些用不上的直接删依赖别留着占体积。删的时候从package.json和components目录两头对只删一边会留下引用报错。验证方法上我自己的习惯是每改完一层就跑三个用例未登录访问受保护页面应跳登录并带redirect登录后访问无权限页面应回首页而不是白屏刷新任意页面菜单高亮和面包屑都正确。这三个过了权限链路基本就通了。再补一个接口 401 的场景确认会不会自动清登录态这条最容易被忘上线后 token 过期用户卡在空白页的投诉多半来自这里。最后说个改造技巧新增业务模块时别直接在views下开新目录就写先照着现有模块把api、views、路由meta、菜单配置四处同步建好跑通一个空页面再加功能。我从一次改到一半发现菜单漏配、路由没注册、接口前缀写错三处一起返工之后每次接新后台项目都强制走一遍这套骨架流程宁可多花半小时也别让业务代码长在歪地基上。希望帮到你。本文还有配套的精品资源点击获取