
简介一套基于 Vue3、Vite、TypeScript、Ant Design Vue 与 Pinia 构建的后台管理系统项目源码面向具备一定前端基础、希望熟悉现代 Vue 工程化实践与中后台页面开发的读者也可作为企业级管理系统的起步模板。项目围绕组合式 API、类型安全、组件库封装、状态模块化与路由自动化展开涵盖异步组件加载、页面缓存、环境变量配置和代码规范约束等常见工程需求。压缩包共 172 个文件以 74 个 .vue 单文件组件和 63 个 .ts 脚本为主辅以 JSON 配置、Less 样式、图片及构建与规范配置文件整体约 232KB体量轻便、目录层次清晰便于按模块检索阅读。已有 1196 人学习读者可从中了解中后台项目的页面组织方式、Pinia store 拆分、Keep-Alive 缓存策略、Vite 自动路由生成以及 ESLint、Stylelint 等规范配置适合对照实践并抽取可复用写法。1. 拿到一个 Vue3 后台管理模板先别急着 npm run dev下载回来的后台模板十有八九在npm install那一步就开始给你脸色看。我见过太多人把一个 Vue3 前端项目解压完直接敲命令屏幕上滚出一串 peer dependency 冲突然后整个文件夹就进了回收站。其实这类模板的价值从来不在“能不能一次跑通”而在于它把中后台系统里最枯燥的那层骨架——登录、路由、菜单、权限、请求封装、布局切换——按主流的写法摆好了位置。Vue3 Vite TypeScript Ant Design Vue Pinia 这套组合是当下中后台前端很常见的落子Vite 负责开发期的构建速度TypeScript 负责接口与数据的类型边界Ant Design Vue 负责组件和交互的一致性Pinia 负责跨页面的状态。它适合已经会写页面、但每次起新项目都要重新搭一遍权限和布局的人也适合想看看别人怎么组织目录、怎么拆 store 的人。下面按我实际拆包改造的顺序走一遍。2. 技术栈拆解四个依赖各自守哪一段业务2.1 Vite开发期的速度换来了什么又牺牲了什么后台系统的页面数量通常在三四十个以上路由一动就是整棵树重编译。Vite 在开发环境不做整体打包浏览器直接按 ESM 请求模块改一个.vue文件只有那个模块和它的 HMR 边界失效冷启动基本在秒级。代价是它必须先把 CommonJS 格式的第三方依赖预构建成 ESM这一步由 esbuild 完成产物落在node_modules/.vite里依赖变了才会重建。预构建偶尔会漏。表现是页面白屏、控制台报does not provide an export named或者某个 UI 库的子组件加载时找不到。这时候别急着重装先看optimizeDeps的配置。我一般会在配置文件里显式列出那几个容易被漏掉的包让它强制预构建。// 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 { fileURLToPath, URL } from node:url export default defineConfig(({ mode }) { // 第三个参数传空字符串才能读到不带 VITE_ 前缀的变量比如代理目标 const env loadEnv(mode, process.cwd(), ) return { // 部署到二级目录时必须改否则静态资源全部 404 base: env.VITE_BASE || /, resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } }, plugins: [ vue(), Components({ resolvers: [ AntDesignVueResolver({ importStyle: false, // 样式由 reset.css 统一入口避免每个组件重复注入 resolveIcons: true }) ], dts: src/types/components.d.ts // 自动生成的组件类型声明要进版本库 }) ], optimizeDeps: { // 这几个包在按需引入时容易漏预构建显式写进来 include: [dayjs, lodash-es, axios] }, server: { port: 5173, proxy: { /api: { target: env.VITE_PROXY_TARGET, // 从 .env.development 读别硬编码 changeOrigin: true, rewrite: (p) p.replace(/^\/api/, ) // 后端没有 /api 前缀时去掉 } } }, build: { chunkSizeWarningLimit: 1500, rollupOptions: { output: { manualChunks: { vue: [vue, vue-router, pinia], antd: [ant-design-vue] } } } } } })参数上有几个点值得说清。base是部署路径如果最终挂在https://域名/admin/下这里必须是/admin/并且路由要用createWebHistory(/admin/)两处对不上就会出现“首页能开、刷新 404、静态资源全丢”的经典组合。importStyle: false是因为 Ant Design Vue 4 之后把样式收进了组件自身再让 resolver 逐组件注入样式反而会重复入口处统一引一次reset.css更干净。manualChunks把 vue 全家桶和组件库拆成独立 chunk是为了让业务代码改动能命中浏览器缓存——业务包天天变这两个包几个月才变一次。生产构建走的是 Rollup和开发期不是一套逻辑。所以“开发跑得飞快”不代表“打包体积小”这是两件事别混为一谈。2.2 TypeScript类型该卡在接口层还是组件层后台系统里 TS 最常见的两种用法一种是“只给 axios 加个泛型其他全靠 any”另一种是“每个 props 都写三行 interface业务逻辑反而没人看”。我的做法是分三层卡第一层是接口返回。后端返回的结构是外部契约必须显式建模因为它是唯一你控制不了的东西。第二层是 store 的 state它决定了跨页面数据的形状写错了会在很远的地方炸。第三层才是组件的 props 和 emits这一层按需写只给被复用的业务组件写页面级组件不写反而更省事。// src/types/api.d.ts // 后端统一响应外壳code 为 0 表示成功 export interface ApiResultT { code: number data: T message: string } export interface PageQuery { page: number pageSize: number } export interface PageResultT { list: T[] total: number }// src/utils/request.ts import axios, { type AxiosInstance, type AxiosRequestConfig } from axios import { message } from ant-design-vue import { useUserStore } from /stores/user import type { ApiResult } from /types/api const instance: AxiosInstance axios.create({ baseURL: import.meta.env.VITE_API_BASE, // 必须是 VITE_ 前缀否则注入不到客户端 timeout: 15000 }) instance.interceptors.request.use((config) { // 注意这里必须在 app.use(pinia) 之后才会执行否则 getActivePinia 报错 const token useUserStore().token if (token) config.headers.Authorization Bearer ${token} return config }) instance.interceptors.response.use( (res) { const body res.data as ApiResultunknown if (body.code ! 0) { message.error(body.message || 请求失败) return Promise.reject(new Error(body.message)) } return body.data as never // 这里剥壳让外层拿到的就是 data }, (err) { if (err.response?.status 401) useUserStore().logout() return Promise.reject(err) } ) // 对外的唯一出口泛型 T 描述的是剥壳之后的 data 形状 export function requestT(config: AxiosRequestConfig): PromiseT { return instance.request(config) }这段的关键在最后那个requestT。拦截器里已经把外壳剥掉了但 axios 的类型定义不知道这件事所以要用一个薄封装把返回类型重新声明成PromiseT。业务侧调用就变成requestPageResultUserItem({ url: /user/list })res.list直接有类型提示不用再点进去看后端返回了什么。还有一个容易被忽略的编译选项如果tsconfig里开了verbatimModuleSyntax或isolatedModules所有纯类型的导入必须写import type否则编译会报“该导入在运行时不存在”。这不是玄学是 Vite 逐文件转译时无法判断某个导入是不是类型只能靠语法标记。2.3 Ant Design Vue按需引入、主题变量与表单校验按需引入靠的是unplugin-vue-components加官方 resolver模板里直接写a-table插件在编译时把它转成具体的 import同时生成components.d.ts给 TS 用。这个声明文件一定要提交到版本库否则同事拉下来会看到满屏的“找不到组件 a-table”。主题定制不要改源码里的 less 变量用ConfigProvider的 token 就行。中后台最常改的是主色、圆角、表格行高这三项改完整个组件库一致跟随。!-- src/App.vue -- template a-config-provider :theme{ token: { colorPrimary: #1677ff, borderRadius: 4, fontSize: 14 }, components: { Table: { cellPaddingBlock: 10 } } } :localezhCN router-view / /a-config-provider /template script setup langts import zhCN from ant-design-vue/es/locale/zh_CN import dayjs from dayjs import dayjs/locale/zh-cn dayjs.locale(zh-cn) // 日期组件的中文不设会显示英文月份 /scriptcomponents这一层是组件级覆盖只在token满足不了的时候用。表单校验则是后台系统里最高频的部分Form的rules写完一定要拿formRef.value.validate()的回调兜住失败分支不能只写await validate()然后不管 catch否则用户点提交没反应你会以为是接口挂了。2.4 Piniastore 按领域拆别按页面拆一个常见的坏味道是“一个页面一个 store”结果用户列表页和用户详情页互相 import改一处崩两处。合理的拆法是按领域用户会话、权限、字典缓存、全局 UI 状态侧边栏折叠、主题、标签页四个够了。列表页的筛选条件这类东西属于页面局部状态用ref放在组件里就行不要往 store 里塞。写法上我偏向 setup store因为它和组件的写法一致computed、watch都能直接用不需要记 options 那一套映射规则。// src/stores/user.ts import { defineStore } from pinia import { ref, computed } from vue import { request } from /utils/request interface Profile { id: string name: string roles: string[] } export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(token) || ) const profile refProfile | null(null) const permissions refstring[]([]) const isLogin computed(() Boolean(token.value)) async function login(payload: { username: string; password: string }) { const res await request{ token: string }({ url: /auth/login, method: post, data: payload }) token.value res.token localStorage.setItem(token, res.token) } async function fetchProfile() { const res await request{ user: Profile; permissions: string[] }({ url: /auth/profile }) profile.value res.user permissions.value res.permissions } function logout() { token.value profile.value null permissions.value [] localStorage.removeItem(token) } return { token, profile, permissions, isLogin, login, fetchProfile, logout } })token落 localStorage 是为了刷新不掉登录profile和permissions故意不落盘每次刷新重新拉一次——权限数据落在客户端是可篡改的虽然前端权限本来就不是安全边界但至少别让它一直留在本地。3. 从解压到登录成功跑通一条完整链路3.1 环境对齐与依赖安装先看package.json里的engines和packageManager字段这两个字段决定了你该用什么 Node 版本和包管理器。Vite 的新版本通常要求 Node 18 以上Node 16 会在启动时直接报语法错误。如果压缩包里带了pnpm-lock.yaml就用 pnpm带了package-lock.json就用 npm。混用会拿到一套和作者不同的依赖树后面所有诡异问题都从这开始。peer 冲突的处理要克制。npm install --legacy-peer-deps能装上去但它只是让 npm 闭嘴不解决真实的版本不兼容。我的顺序是先看报错里点名的两个包是谁再看能不能升其中一个实在不行才降级。# 1. 对齐 Node 版本有 .nvmrc 就直接用 cat .nvmrc nvm use # 2. 按 lock 文件选包管理器不要两个都跑 pnpm install --frozen-lockfile # 3. 启动后先看有没有预构建报错 pnpm dev--frozen-lockfile的作用是锁文件与package.json不一致时直接失败而不是偷偷帮你升级。第一次装依赖时建议带上能提前发现“作者改了 package.json 但忘了更新 lock”的情况。3.2 入口链路main.ts 到 router-viewmain.ts的注册顺序是有讲究的顺序错了会出现“路由守卫里拿不到 store”或者“组件样式被覆盖”这类难查的问题。正确顺序是创建 app → 装 pinia → 装 router → 注册全局指令 → 挂载。pinia 必须在 router 之前因为路由守卫里会用到 store。// src/main.ts import { createApp } from vue import { createPinia } from pinia import App from ./App.vue import router from ./router import { permission } from ./directives/permission import ant-design-vue/dist/reset.css // 组件库基础样式必须在业务样式之前 import ./styles/index.less const app createApp(App) app.use(createPinia()) // 必须早于 router守卫里要用 store app.use(router) app.directive(permission, permission) app.mount(#app)样式顺序是个隐形坑reset.css放在业务样式后面会把你自己写的按钮圆角、表格边框全冲掉。业务样式永远最后引入。3.3 路由与侧边菜单的联动中后台的菜单基本都是根据路由表生成的不要在菜单组件里再手写一遍结构那是双份维护。做法是给每条路由的meta打上title、icon、hidden三个标记菜单组件递归读取路由表转成a-menu的items。// src/layouts/useMenu.ts import type { RouteRecordRaw } from vue-router interface MenuItem { key: string label: string icon?: string children?: MenuItem[] } export function toMenuItems(routes: RouteRecordRaw[], parentPath ): MenuItem[] { return routes .filter((r) !r.meta?.hidden) // hidden 的路由不进菜单比如详情页 .map((r) { // 子路由用相对路径时要拼出完整 key否则菜单高亮对不上 const fullPath r.path.startsWith(/) ? r.path : ${parentPath}/${r.path}.replace(/\//g, /) return { key: fullPath, label: (r.meta?.title as string) ?? r.name?.toString() ?? , icon: r.meta?.icon as string | undefined, children: r.children?.length ? toMenuItems(r.children, fullPath) : undefined } }) }这里最容易出问题的是key。菜单的selectedKeys是拿当前路由的path去比的如果子路由配置的是相对路径path: list菜单 key 拼出来的和route.path对不上就会出现“进了页面但菜单不高亮”。把key统一成完整路径这个问题就没了。3.4 请求封装与登录态请求封装在 2.2 已经写过这里补登录态的两个边界。第一个是 401 的处理拦截器里收到 401 不能只弹个提示要清 token 并跳登录页同时把当前路径塞进redirect参数登录完能回到原页面。第二个是重复请求登录接口和某些导出接口在用户连点时会被打两次简单做法是在拦截器里给config挂一个pending的 Map相同method url params的请求直接 reject 前一个。登录页本身没什么好讲的注意两点就行密码框要autocompletecurrent-password否则浏览器的密码管理器会乱填提交按钮要有 loading 态并且禁用防连点比什么都实在。4. 权限与动态菜单这类模板最容易翻车的一块4.1 前端静态权限 vs 后端下发菜单两种模型各有适用场景选错了后面全是补丁。静态权限是路由表写死在前端每条路由的meta.roles标明哪些角色能进登录后拿用户角色做过滤。优点是路由结构清晰、类型完整、改起来直观缺点是加一个页面要发一次前端版本。动态菜单是登录后后端返回一棵菜单树前端按树去addRoute。优点是权限完全由后端控制运营能自己配缺点是前端拿不到完整的路由类型组件路径要靠import.meta.glob动态匹配出错了就是白屏。我的判断标准是如果这个系统的角色不超过五种、页面结构相对稳定用静态权限如果是多租户 SaaS、每家客户的菜单都不一样才上动态菜单。别为了“显得高级”选动态后面调试成本会教你做人。4.2 动态路由注入的时机不管用哪种模型只要涉及“登录后才拿到路由”就必须在路由守卫里注入而且要注意两件事注入完必须return { ...to, replace: true }重新进入一次否则当前这次导航用的是注入前的路由表会直接命中 404。// src/router/guard.ts import router from ./index import { useUserStore } from /stores/user import { buildRoutes } from ./dynamic const WHITE_LIST [/login, /404] router.beforeEach(async (to) { const user useUserStore() if (WHITE_LIST.includes(to.path)) return true if (!user.isLogin) { return { path: /login, query: { redirect: to.fullPath } } } // profile 为空说明是首次进入或刷新需要拉权限并注入路由 if (!user.profile) { await user.fetchProfile() const routes buildRoutes(user.profile.roles) routes.forEach((r) router.addRoute(r)) // 关键换一个 location 重新触发导航让新加的路由生效 return { ...to, replace: true } } return true })replace: true不能省。不加的话浏览器历史里会多一条记录用户点后退会回到注入前那次导航然后又被守卫弹回来形成“后退键失灵”的错觉。这是血泪经验不是理论。4.3 按钮级权限指令菜单级权限挡住的是页面入口按钮级权限挡的是操作。后端当然要校验但前端不挡会让用户点了之后看到一个 403 弹窗体验很差。// src/directives/permission.ts import type { Directive } from vue import { useUserStore } from /stores/user // 用法v-permissionuser:delete 或 v-permission[user:edit,user:delete] export const permission: DirectiveHTMLElement, string | string[] { mounted(el, binding) { const store useUserStore() const need Array.isArray(binding.value) ? binding.value : [binding.value] const ok need.some((code) store.permissions.includes(code)) // 没有权限直接把节点摘掉而不是 display:none避免被开发者工具改回来 if (!ok) el.parentNode?.removeChild(el) } }用removeChild而不是v-if或者display:none是因为指令拿不到父组件的响应式上下文用v-if需要把权限判断写进模板反而更啰嗦。摘节点的代价是权限变化后不会自动恢复所以这个指令适合“登录后权限不再变”的场景如果权限会动态刷新还是老老实实用v-if加一个hasPermission()方法。4.4 权限契约与刷新丢失刷新丢权限是这套架构最高频的报障。原因很直接addRoute加进去的路由只存在于内存里刷新之后路由表回到初始状态但 localStorage 里的 token 还在于是守卫认为已登录、直接放行结果目标路由不存在落进 404。解决办法就是 4.2 里的那段用profile是否为空作为“路由是否已注入”的标志为空就重新拉权限重新注。这里有个隐含前提——profile不能持久化。如果为了省一次请求把profile也存进 localStorage刷新后它不为空守卫跳过注入页面照样白屏。这个坑我在两个项目里见过。还有 404 兜底路由的注册顺序。通配路由path: /:pathMatch(.*)*必须在动态路由全部注入完之后才能生效所以它应该写在静态路由里、但不放在addRoute之前的那批。稳妥做法是把通配路由单独在守卫里最后addRoute或者干脆用router.hasRoute()判断后再补。5. 避坑排查这套组合落地时最常卡住的五处5.1 依赖与构建类现象pnpm dev启动成功但页面白屏控制台只有一行Failed to resolve import。原因是某个组件用了路径别名/但vite.config.ts里的resolve.alias和tsconfig.json里的paths没同步。Vite 认前者TS 认后者只配一个就会出现“编辑器不报错但运行报错”或者反过来。解决是把两处对齐并且tsconfig的baseUrl指向项目根、paths写成{/*: [src/*]}两边一个字都不能差。现象打包成功部署到服务器后首页能开刷新任意子路由 404静态资源也 404。这是两个问题叠在一起。资源 404 是base没配对仓库挂在/admin/下就必须写base: /admin/。路由 404 是 history 模式的服务端回退没配Nginx 要加try_files $uri $uri/ /admin/index.html;。很多人只改了一处然后以为是另一方的问题来回折腾半天。现象本地开发一切正常pnpm build时报Cannot find module xxx或者类型错误。原因是 dev 模式下 Vite 不做类型检查vue-tsc只在build脚本里跑。也就是说你写了两周的any和类型错误只有打包那一刻才暴露。习惯做法是在package.json里加一个typecheck: vue-tsc --noEmit提交前跑一遍别攒到最后。5.2 运行时与样式类现象路由守卫里调useUserStore()报getActivePinia was called but there was no active Pinia。原因是request.ts在模块顶层被 import而它内部又 import 了 store形成了一条在app.use(createPinia())之前就执行的链路。解决是把 store 的调用挪到函数体内部像 2.2 那样写在拦截器回调里不要在模块顶层const store useUserStore()。这条规则适用于所有工具模块不只是请求封装。现象a-table的表头样式正常但表格内容区的字体、行高和设计稿对不上改 less 变量也没用。原因是 Ant Design Vue 4 之后样式走 CSS-in-JS优先级比你写在.less里的选择器高。正确做法是用ConfigProvider的components.Table做组件级 token 覆盖或者在需要精细控制的少数地方加:deep()并提高选择器权重。硬怼!important能赢一时升级组件库时会全部失效。现象动态路由注入后浏览器后退键失效或者点菜单一直在同一个页面打转。回到 4.2 那条addRoute之后没有return { ...to, replace: true }。守卫放行的是“注入前解析出来的路由对象”注入后需要重新解析一次。加replace: true是为了不污染历史栈。这个改动只有一行但不知道的人能查一整天。6. 二次开发把模板改成能交付的骨架模板跑通只是起点真正交付前还有两件事要做环境变量分层和首屏体积控制。环境变量这块Vite 只把VITE_前缀的变量注入客户端其余的在vite.config.ts里通过loadEnv读用来配代理目标和base。我一般建三份.env.development配代理、.env.staging配测试域名、.env.production配正式域名和base。VITE_API_BASE在不同环境里分别指向/api、https://测试域名/api、/api生产走同域反向代理能省掉一整类跨域和证书问题。变量开发预发生产说明VITE_BASE///admin/与路由 base 必须一致VITE_API_BASE/api预发完整地址/api生产走同域反代VITE_PROXY_TARGET后端本地地址不使用不使用只给 dev server 用构建脚本按模式拆开build:staging: vue-tsc --noEmit vite build --mode staging。类型检查放在构建前面是用卡住的——检查不过就不打包避免把类型错误带到线上。首屏体积的排查顺序是固定的先看manualChunks有没有把 vue 和组件库拆出去第 2 章那一段再看路由是不是全量同步引入。后台系统几十个页面全塞进一个 chunk首屏必然慢。把所有业务路由改成() import(/views/xxx/index.vue)是收益最大的一步改完首屏 JS 通常能掉一半以上。剩下的零碎优化图片懒加载、字体子集化、gzip/brotli等前面两步做完再考虑顺序反了就是白忙。最后说个习惯。我每次拿到一个新的后台模板第一件事不是改首页而是把main.ts、router/index.ts、router/guard.ts、utils/request.ts、stores/user.ts这五个文件从头读一遍在纸上画出“启动 → 守卫 → 拉权限 → 注路由 → 渲染”这条线画不出来就说明这个模板的链路有我看漏的地方先别动手改。那次在一个项目里跳过这步直接改业务结果动态路由和静态路由混着用上线前三天才发现刷新丢权限连夜回滚。从那以后我每次接新模板都强制走一遍这条链路画得出来再写第一行业务代码。希望帮到你。本文还有配套的精品资源点击获取