Vue3+Vite+TypeScript中后台管理系统脚手架实战 简介面向Vue3前端开发者与需要搭建后台管理系统的学习者的实战源码包基于Vue3、Vite、TypeScript、Ant Design Vue、Pinia等主流技术组合重点解决后台项目中路由组织、状态管理、类型约束与界面搭建等常见问题兼顾入门练习与企业级项目参考。压缩包共172个文件约232KB以74个Vue组件和63个TypeScript文件为主配合JSON配置、Less样式、图片与工程化配置文件能够较为完整地呈现页面结构、业务逻辑与构建配置。已有1196人学习下载具备一定参考热度。项目覆盖Composition API、TypeScript类型推断、Ant Design Vue组件应用、Pinia模块化状态管理、Keep-Alive页面缓存以及Vite自动路由生成等内容读者可据此理解后台系统的目录组织、组件拆分与状态流转方式并在此基础上进行二次开发或针对性练习。1. 一套能把后台系统从 0 跑到上线的前端脚手架接手一个中后台需求真正耗时间的往往不是写页面而是把登录态、路由权限、菜单渲染、请求封装、表格分页这些“每次都要重来一遍”的东西先搭起来。这套 Vue3 Vite TypeScript Ant Design Vue Pinia 的后台管理系统模板就是冲着这件事来的Vite 负责秒级冷启动和热更新TypeScript 把接口字段、路由 meta、组件 props 约束住Pinia 管全局状态和用户信息Ant Design Vue 提供现成的表格、表单、弹窗与布局骨架。登录页、侧边菜单、面包屑、标签页、权限控制、主题切换这些骨架件基本开箱即用。它适合两类人一类是要给内部做运营后台、数据看板、审批流系统又不想从零攒脚手架的开发者另一类是想认真读一遍 Vue3 组合式 API 工程化落地写法的人。下面按“能干什么、怎么跑、怎么改”的次序把这份资源拆开看。2. 技术栈分工与目录结构五件套各管哪一段先讲清楚为什么是这五个东西凑一起而不是随便堆流行词。中后台系统的特征是页面多、表单重、权限细、生命周期长所以选型的第一优先级不是“新”而是“改起来不疼”。2.1 五个核心依赖各自解决的问题Vue3 的组合式 API 在中后台场景下最大的价值是逻辑可以按功能切块。一个列表页往往同时有搜索条件、表格数据、分页、新增/编辑弹窗、批量操作用setup写就是五块互不干扰的代码而选项式 API 会把这些拆散到data、methods、computed里来回跳。script setup编译后还会把顶层绑定直接暴露给模板少一层return模板里改个变量名编辑器能立刻提示。Vite 解决的是开发体验。中后台项目模块数量上去以后冷启动和 HMR 的速度直接决定一天能迭代几轮。Vite 用 esbuild 预构建依赖浏览器原生 ESM 按需加载源码改一个.vue文件只重编译那一个模块几百个页面的项目热更新依然是一百多毫秒的量级。TypeScript 在这里不是“为了用而用”。中后台最痛的是接口字段变动——后端把createTime改成createdAt没有类型检查就得靠运行时报错和肉眼排查。把接口返回结构、路由meta、请求封装的泛型都标上类型改字段时编辑器一排红波浪线比测试环境点半天靠谱得多。Ant Design Vue 提供的是中后台的“标准件”。Table 的列配置、分页、固定列、行选择Form 的校验规则、栅格布局Modal、Drawer、Tree、Transfer、Upload 这些组件自己写一套至少两周。需要注意的是 Vue 2 时代的 1.x 和 Vue 3 时代的 4.x 不是一套 API网上很多老示例直接抄过来会报错认准 4.x 的写法。Pinia 替代 Vuex 的理由很实际没有 mutation少一层样板对 TypeScript 的类型推导友好store 天然按文件拆分不用手动维护一个大对象。用户信息、权限码、菜单树、全局 loading、主题配置这些跨页面共享的状态都塞这里。除了这五个工程里通常还会带 Vue Router 4路由与守卫、Axios请求、dayjs时间格式化Ant Design Vue 的日期组件本身就依赖它、vueuse/core常用组合式工具函数以及 ESLint Prettier 的代码规范链。2.2 目录结构逐层拆解拿到压缩包解压后先别急着npm install把目录看一眼后面定位问题会快很多。典型的目录长这样src/ ├── api/ # 接口定义按业务模块分文件 │ ├── user.ts │ └── system.ts ├── assets/ # 静态资源会被构建处理 ├── components/ # 全局通用组件 │ ├── TablePanel/ # 表格 搜索栏的组合封装 │ └── SvgIcon/ ├── directives/ # 自定义指令权限指令在这里 │ └── permission.ts ├── layouts/ # 布局骨架 │ ├── BasicLayout.vue # 侧边栏 顶栏 内容区 │ └── components/ ├── router/ # 路由 │ ├── index.ts # 实例创建与全局守卫 │ ├── routes.ts # 静态路由表 │ └── guard.ts # 权限守卫逻辑 ├── stores/ # Pinia │ ├── index.ts # createPinia 与持久化插件 │ ├── user.ts # 用户信息、token、权限码 │ └── app.ts # 主题、折叠状态、标签页 ├── styles/ # 全局样式与变量 ├── utils/ # 工具函数 │ ├── request.ts # Axios 封装 │ └── auth.ts # token 存取 ├── views/ # 页面按业务模块建目录 │ ├── login/ │ ├── dashboard/ │ └── system/ ├── App.vue └── main.ts几个位置的分工要提前记住api/只放请求函数和类型定义不写业务逻辑views/里的页面只做组装和交互utils/request.ts是全局唯一的请求出口任何页面都别自己去import axioslayouts/是骨架页面本身不关心侧边栏长什么样。这套边界守住了后面加二十个页面也不会乱。components/TablePanel这类组合封装值得单独看一眼它一般把「搜索表单 表格 分页器」打包成一个组件页面只需要传列定义、搜索项配置和请求函数。中后台里这类页面占七成以上封装一次能省掉大量重复代码。2.3 环境变量与构建配置跑之前先确认 Node 版本。Vite 5 这一代要求 Node 18 以上用node -v看一眼低了会直接报crypto.hash is not a function这类看不懂的错。环境变量走.env文件Vite 只暴露VITE_前缀的变量给客户端代码# .env.development VITE_APP_TITLE 运营管理后台 VITE_API_BASE_URL /api VITE_APP_PORT 5173 # .env.production VITE_APP_TITLE 运营管理后台 VITE_API_BASE_URL https://api.example.comVITE_API_BASE_URL在开发环境写成/api配合 Vite 的代理转发到本地或测试后端这样浏览器里不存在跨域cookie 也能正常带上。生产环境换成真实域名打包时由vite build --mode production自动读取。再看vite.config.ts这是整个工程的中枢import { defineConfig, loadEnv } from vite import vue from vitejs/plugin-vue import Components from unplugin-vue-components/vite import { AntDesignVueResolver } from unplugin-vue-components/resolvers import { resolve } from path export default defineConfig(({ mode }) { // 按当前 mode 读取对应的 .env 文件 const env loadEnv(mode, process.cwd()) return { plugins: [ vue(), // 组件按需自动引入模板里写 a-table 不用手动 import Components({ resolvers: [ AntDesignVueResolver({ importStyle: false, // 4.x 用 cssinjs不需要再引 less 样式文件 }), ], dts: src/components.d.ts, // 自动生成的类型声明 }), ], resolve: { alias: { // 路径别名TS 那边要在 tsconfig 里同步一份 : resolve(__dirname, src), }, }, server: { port: Number(env.VITE_APP_PORT) || 5173, open: true, proxy: { /api: { target: http://127.0.0.1:8080, // 后端地址 changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ), // 去掉前缀再转发 }, }, }, build: { chunkSizeWarningLimit: 1500, rollupOptions: { output: { // 手动分包把不常变的大依赖单独抽出来利于浏览器缓存 manualChunks: { vue: [vue, vue-router, pinia], antd: [ant-design-vue], }, }, }, }, } })alias里的只是个约定改成别的也行但必须在tsconfig.json的compilerOptions.paths里同步一份否则编辑器会报“找不到模块”而运行时又是好的——这个不一致是新手最容易卡住的地方之一。proxy的rewrite要不要写取决于后端接口本身带不带/api前缀写之前先用 curl 直接打一下后端确认。manualChunks把 vue 全家桶和 antd 拆成独立的 chunk业务代码改动时这两个文件的 hash 不变用户第二次访问可以直接命中缓存。package.json的脚本一般就这几条npm run dev # 开发 npm run build # 生产构建产物在 dist/ npm run preview # 本地预览 dist 产物 npm run lint # ESLint 检查npm run preview这一步别跳过。开发环境跑通不代表打包后能跑资源路径、环境变量、按需引入的样式问题都只会在产物里暴露。3. 启动链路拆解一个请求从进入到返回的完整路径从浏览器打开到页面渲染出数据这条链路上的每一环都可能是你后面要改的地方所以值得完整走一遍main.ts装配 → 路由守卫校验登录态 → 布局渲染菜单 → 页面组件onMounted发请求 → Axios 拦截器补 token → 响应拦截统一处理错误 → 数据回填表格。3.1 main.ts 的装配顺序main.ts很短但顺序有讲究import { createApp } from vue import App from ./App.vue import router from ./router import pinia from ./stores import { setupDirectives } from ./directives import virtual:uno.css // 如果项目用了原子化 CSS import /styles/index.less const app createApp(App) // 顺序建议pinia - router - 指令 // 因为路由守卫里会读 storestore 必须先装好 app.use(pinia) app.use(router) setupDirectives(app) // 注册 v-permission 等自定义指令 app.mount(#app)关键点在于pinia必须早于router。路由守卫里通常会调useUserStore()判断有没有 token、有没有权限而 Pinia 没装好时调用会直接抛getActivePinia was called with no active Pinia。这个报错信息很直白但出现在router/index.ts里的时候很多人第一反应是去查路由配置方向反了。setupDirectives(app)是自定义指令的统一注册入口权限指令、防抖指令、复制指令都在directives/下按文件拆最后在这里汇总。这么做的好处是main.ts不会被一堆app.directive撑爆。3.2 路由表、懒加载与动态路由静态路由表定义不需要权限的页面比如登录页、404、403import type { RouteRecordRaw } from vue-router export const constantRoutes: RouteRecordRaw[] [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { title: 登录, hidden: true }, }, { path: /, component: () import(/layouts/BasicLayout.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { title: 工作台, icon: dashboard, affix: true }, }, ], }, ]component: () import(...)是路由级懒加载每个页面单独打一个 chunk首屏只加载当前路由需要的代码。meta里几个字段各有用途title用于面包屑、标签页和document.titleicon用于侧边菜单渲染hidden为 true 的路由不进菜单affix表示标签页固定不可关闭。真正有权限区分的系统会用动态路由登录后调接口拿到菜单树前端把后端返回的组件路径字符串映射成真实的组件对象再router.addRoute()挂上去。常见做法是维护一个views/**/*.vue的 glob 映射表// 用 Vite 的 glob 把 views 下所有页面收集成 路径 - 组件 的映射 const modules import.meta.glob(/views/**/*.vue) function resolveComponent(path: string) { const key /src/views/${path}.vue if (!modules[key]) { console.warn(路由组件不存在${path}) return () import(/views/error/404.vue) } return modules[key] }这段代码的价值在于兜底。后端菜单表里配错一个路径如果没有这个console.warn表现就是点菜单白屏控制台一声不吭排查起来像开盲盒。加上日志一眼就能看出是哪个路径对不上。全局守卫通常做四件事进度条、白名单放行、token 校验、标题设置。router.beforeEach(async (to) { NProgress.start() const userStore useUserStore() const whiteList [/login, /404] if (userStore.token) { if (to.path /login) return { path: / } // 首次进入时拉取用户信息并挂载动态路由 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() return { ...to, replace: true } // 重新进入一次让新路由生效 } catch { userStore.resetToken() return { path: /login, query: { redirect: to.fullPath } } } } return true } if (whiteList.includes(to.path)) return true return { path: /login, query: { redirect: to.fullPath } } }) router.afterEach((to) { NProgress.done() document.title to.meta.title ? ${to.meta.title} - ${import.meta.env.VITE_APP_TITLE} : 管理后台 })return { ...to, replace: true }这一行是动态路由能生效的关键。刚addRoute完直接return true本次导航匹配的还是旧路由表会掉到 404重新触发一次导航才能匹配上新挂的路由。redirect参数用来在登录成功后跳回原来想去的页面这个细节用户感知很强但经常被漏掉。3.3 按钮级权限与菜单渲染路由级权限拦住了“能不能进这个页面”按钮级权限管的是“能不能点这个按钮”。常见做法是一个自定义指令// directives/permission.ts import type { Directive, DirectiveBinding } from vue import { useUserStore } from /stores/user export const permission: Directive { mounted(el: HTMLElement, binding: DirectiveBindingstring[]) { const userStore useUserStore() const codes userStore.permissionCodes // 例如 [system:user:add] const need binding.value // 使用方传入的权限码数组 // 只要有一个命中就保留元素 const hasAuth need.some((code) codes.includes(code)) if (!hasAuth) { el.parentNode?.removeChild(el) // 直接移除比 display:none 更彻底 } }, }用法是a-button v-permission[system:user:add]新增用户/a-button。参数说明binding.value是调用方传入的权限码类型是数组支持“或”关系codes来自 store一般是登录后由后端下发的扁平权限码集合。用removeChild而不是v-if或display: none是为了防止用户在控制台把元素改回来。当然前端权限永远只是体验层后端接口必须自己再校验一次这一点别省。侧边菜单渲染依赖同一份路由数据。遍历router.options.routes过滤meta.hidden把meta.icon映射成图标组件path拼成菜单的 key。做成递归组件就能支持无限级菜单。标签页多页签则监听路由变化把meta.affix的路由固定、其余按访问顺序入栈配合keep-alive的include做页面缓存。4. 状态与数据层Pinia 模块化与 Axios 拦截器链页面写多了会发现真正影响可维护性的不是组件写得好不好看而是状态和数据请求有没有统一入口。这一层乱掉项目超过三十个页面就开始互相污染。4.1 Pinia 的模块拆分与持久化用 setup 语法定义 store比 options 写法更贴近组合式 API 的心智// stores/user.ts import { defineStore } from pinia import { ref, computed } from vue import { loginApi, getUserInfoApi } from /api/user import { getToken, setToken, removeToken } from /utils/auth export const useUserStore defineStore( user, () { const token refstring(getToken() || ) const userInfo refUserInfo | null(null) const permissionCodes refstring[]([]) const userName computed(() userInfo.value?.nickname ?? 未登录) async function login(payload: LoginParams) { const { data } await loginApi(payload) token.value data.token setToken(data.token) // 同步写一份到 localStorage刷新后能恢复 } async function fetchUserInfo() { const { data } await getUserInfoApi() userInfo.value data.user permissionCodes.value data.permissions return data } function resetToken() { token.value userInfo.value null permissionCodes.value [] removeToken() } return { token, userInfo, permissionCodes, userName, login, fetchUserInfo, resetToken } }, { // 只持久化 token用户信息每次刷新重新拉避免后端改了权限前端还拿着旧的 persist: { paths: [token] }, }, )逻辑说明token初始化时从localStorage读保证刷新页面不掉登录登录成功后写内存同时写本地存储fetchUserInfo每次都重新拉因为权限是会变的缓存用户信息属于给自己挖坑。persist配置需要pinia-plugin-persistedstate插件支持paths精确控制哪些字段落盘比整个 store 全存要安全。参数上要注意persist的storage默认是localStorage如果要控制过期时间得换成sessionStorage或自己包一层带时间戳的存储。另外paths在部分插件版本里叫pick写之前看一眼node_modules里的类型定义两分钟的事别硬猜。stores/index.ts里统一创建并注册插件import { createPinia } from pinia import piniaPluginPersistedstate from pinia-plugin-persistedstate const pinia createPinia() pinia.use(piniaPluginPersistedstate) export default piniastore 拆分的粒度建议按“生命周期一致”来划用户信息、权限码、token 放一个主题、侧边栏折叠、标签页列表放app字典数据下拉选项单独一个dict。别按页面拆那样一个页面一个 store最后没人记得哪个 store 里有什么。4.2 Axios 封装拦截器里该放的三件事utils/request.ts是全局唯一的请求出口所有api/下的函数都基于它import axios, { type AxiosInstance, type AxiosResponse } from axios import { message } from ant-design-vue import { useUserStore } from /stores/user import router from /router const service: AxiosInstance axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, headers: { Content-Type: application/json;charsetutf-8 }, }) // 请求拦截补 token、补租户标识 service.interceptors.request.use( (config) { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }, (error) Promise.reject(error), ) // 响应拦截统一拆包、统一错误提示、统一处理 401 service.interceptors.response.use( (response: AxiosResponseApiResultany) { const { code, data, message: msg } response.data if (code 0 || code 200) return data // 直接返回业务数据页面少写一层 .data message.error(msg || 请求失败) return Promise.reject(new Error(msg || Error)) }, (error) { const status error.response?.status if (status 401) { // 登录过期清理状态并跳登录带上当前地址方便回跳 const userStore useUserStore() userStore.resetToken() router.replace({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) } else if (status 403) { message.error(没有操作权限) } else if (status 500) { message.error(服务异常请稍后重试) } else { message.error(error.message || 网络异常) } return Promise.reject(error) }, ) export default service几个设计决定值得展开。第一响应拦截器里直接return data而不是返回整个response页面里就能写const list await getUserList(params)少一层.data.data的嵌套三十个接口能省下不少手误。代价是拿不到响应头如果某个场景需要读Content-Disposition做文件下载得单独用axios.get(url, { responseType: blob })或给这个接口开个旁路。第二业务码判断放在拦截器里页面就不需要每个请求都写一遍if (res.code ! 200)。约定的成功码要和后端对齐0和200两种风格都很常见这里做了兼容。第三401 处理必须集中。如果每个页面各写一遍跳登录迟早出现“某个页面 401 之后不跳转还在死循环请求”的情况。注意resetToken之后要跳转跳转之前最好加个防抖标记否则并发五个请求同时 401会连续触发五次router.replace控制台一片警告。api/user.ts就只剩下纯粹的类型和函数import request from /utils/request export interface LoginParams { username: string password: string } export function loginApi(data: LoginParams) { return request.postLoginResult(/auth/login, data) } export function getUserInfoApi() { return request.getUserInfoResult(/user/info) }4.3 一个列表页的完整写法把上面的东西串起来看一个典型的用户列表页script setup langts import { ref, reactive, onMounted } from vue import { getUserPageApi, deleteUserApi } from /api/system import type { UserItem } from /api/system const loading ref(false) const list refUserItem[]([]) const total ref(0) // 查询条件与分页合并成一个对象重置时整体覆盖即可 const query reactive({ page: 1, size: 10, keyword: , status: undefined as number | undefined }) async function fetchList() { loading.value true try { const { records, total: count } await getUserPageApi(query) list.value records total.value count } finally { loading.value false // 放 finally请求失败也要关 loading } } function onSearch() { query.page 1 // 搜索必须重置页码否则会停在第 5 页查一个只有 1 页结果的条件 fetchList() } function onReset() { Object.assign(query, { page: 1, size: 10, keyword: , status: undefined }) fetchList() } async function onDelete(id: number) { await deleteUserApi(id) fetchList() } onMounted(fetchList) /script template a-card a-form layoutinline a-form-item label关键字 a-input v-model:valuequery.keyword allow-clear placeholder用户名/手机号 press-enteronSearch / /a-form-item a-form-item a-button typeprimary clickonSearch查询/a-button a-button classml-2 clickonReset重置/a-button /a-form-item /a-form a-table :data-sourcelist :loadingloading :pagination{ current: query.page, pageSize: query.size, total, showSizeChanger: true } row-keyid change(p) { query.page p.current; query.size p.pageSize; fetchList() } a-table-column title用户名>a-config-provider :localezhCN :theme{ token: { colorPrimary: #1677ff, borderRadius: 4 }, components: { Table: { headerBg: #fafafa } }, } router-view / /a-config-providertoken是全局变量components下按组件名覆盖单个组件的样式优先级更高。想加暗色模式给theme传{ algorithm: theme.darkAlgorithm }就行theme本身从ant-design-vue导入。这套方式的坑在于别在业务样式里硬写十六进制色值否则切主题时那些地方不会跟着变回头找起来很费劲。加一个业务模块的完整动作是四步在api/下建xxx.ts写接口和类型在views/xxx/下建页面列表页复制一份现成的改字段在router/routes.ts里加路由并在meta里配好title、icon如果是动态路由还要在后端菜单表里加一条记录component字段填xxx/index对应views/xxx/index.vue。四步里最容易漏第四步本地明明加了路由但菜单不显示多半是后端没配。权限改造是最需要谨慎的部分。路由级权限靠动态路由按钮级靠v-permission接口级必须由后端把关。改造时建议先在stores/user.ts里把permissionCodes的结构定清楚——是扁平码system:user:add还是树形菜单权限两种混用会非常痛苦。扁平码判断简单树形适合做菜单渲染我的做法是后端同时返回两份菜单树用于渲染侧边栏扁平码用于按钮判断。改造完怎么验证我一般按这条清单走一遍清空 localStorage 后访问一个内页应该跳登录并带上 redirect登录后应该回到那个内页而不是首页用没有该权限的账号登录对应按钮应该消失直接调接口应该返回 403改一个接口字段名编辑器应该报错npm run build npm run preview走一遍刷新任意子路由不 404。这五条过了基本不会在提测时被打回来。从那以后我每次拿一套新模板做二次开发都强制自己先把request.ts和路由守卫这两个文件从头到尾读一遍再动手哪怕看着眼熟——这两处是所有页面流量的必经之路理解错了后面写多少页面都是返工。希望帮到你。本文还有配套的精品资源点击获取