Vue3+Vite+TypeScript中后台管理系统脚手架实战

发布时间:2026/10/30 2:59:16
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 axios;layouts/是骨架,页面本身不关心侧边栏长什么样。这套边界守住了,后面加二十个页面也不会乱。

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.com

VITE_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 -> 指令 // 因为路由守卫里会读 store,store 必须先装好 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、403:

import 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.title;icon用于侧边菜单渲染;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: DirectiveBinding<string[]>) { 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 = ref<string>(getToken() || '') const userInfo = ref<UserInfo | null>(null) const permissionCodes = ref<string[]>([]) 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 pinia

store 拆分的粒度建议按“生命周期一致”来划:用户信息、权限码、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;charset=utf-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: AxiosResponse<ApiResult<any>>) => { 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.post<LoginResult>('/auth/login', data) } export function getUserInfoApi() { return request.get<UserInfoResult>('/user/info') }

4.3 一个列表页的完整写法

把上面的东西串起来看一个典型的用户列表页:

<script setup lang="ts"> import { ref, reactive, onMounted } from 'vue' import { getUserPageApi, deleteUserApi } from '@/api/system' import type { UserItem } from '@/api/system' const loading = ref(false) const list = ref<UserItem[]>([]) 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 layout="inline"> <a-form-item label="关键字"> <a-input v-model:value="query.keyword" allow-clear placeholder="用户名/手机号" @press-enter="onSearch" /> </a-form-item> <a-form-item> <a-button type="primary" @click="onSearch">查询</a-button> <a-button class="ml-2" @click="onReset">重置</a-button> </a-form-item> </a-form> <a-table :data-source="list" :loading="loading" :pagination="{ current: query.page, pageSize: query.size, total, showSizeChanger: true }" row-key="id" @change="(p) => { query.page = p.current; query.size = p.pageSize; fetchList() }" > <a-table-column title="用户名"><a-config-provider :locale="zhCN" :theme="{ token: { colorPrimary: '#1677ff', borderRadius: 4 }, components: { Table: { headerBg: '#fafafa' } }, }" > <router-view /> </a-config-provider>

token是全局变量,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和路由守卫这两个文件从头到尾读一遍再动手,哪怕看着眼熟——这两处是所有页面流量的必经之路,理解错了,后面写多少页面都是返工。希望帮到你。

本文还有配套的精品资源,点击获取