简介:一套基于 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 ApiResult<T> { code: number data: T message: string } export interface PageQuery { page: number pageSize: number } export interface PageResult<T> { 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 ApiResult<unknown> 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 request<T>(config: AxiosRequestConfig): Promise<T> { return instance.request(config) }这段的关键在最后那个request<T>。拦截器里已经把外壳剥掉了,但 axios 的类型定义不知道这件事,所以要用一个薄封装把返回类型重新声明成Promise<T>。业务侧调用就变成request<PageResult<UserItem>>({ 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 } } }" :locale="zhCN" > <router-view /> </a-config-provider> </template> <script setup lang="ts"> import zhCN from 'ant-design-vue/es/locale/zh_CN' import dayjs from 'dayjs' import 'dayjs/locale/zh-cn' dayjs.locale('zh-cn') // 日期组件的中文,不设会显示英文月份 </script>components这一层是组件级覆盖,只在token满足不了的时候用。表单校验则是后台系统里最高频的部分,Form的rules写完一定要拿formRef.value.validate()的回调兜住失败分支,不能只写await validate()然后不管 catch,否则用户点提交没反应,你会以为是接口挂了。
2.4 Pinia:store 按领域拆,别按页面拆
一个常见的坏味道是“一个页面一个 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 = ref<Profile | null>(null) const permissions = ref<string[]>([]) 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-view
main.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 前一个。
登录页本身没什么好讲的,注意两点就行:密码框要autocomplete="current-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-permission="'user:delete'" 或 v-permission="['user:edit','user:delete']" export const permission: Directive<HTMLElement, 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这五个文件从头读一遍,在纸上画出“启动 → 守卫 → 拉权限 → 注路由 → 渲染”这条线,画不出来就说明这个模板的链路有我看漏的地方,先别动手改。那次在一个项目里跳过这步直接改业务,结果动态路由和静态路由混着用,上线前三天才发现刷新丢权限,连夜回滚。从那以后我每次接新模板都强制走一遍这条链路,画得出来再写第一行业务代码。希望帮到你。
本文还有配套的精品资源,点击获取