简介:基于 Vue3、TypeScript、Vite 与 Element UI 构建的后台管理系统源码,适合有一定前端基础、希望掌握现代工程化整合流程的开发者学习。项目围绕 Vue3 Composition API 组织页面逻辑,同时借助 TypeScript 的接口、泛型与类型推导降低协作成本,依靠 Vite 获得快速的冷启动和模块热更新,界面层则用 Element UI 高频组件快速搭出后台管理所需表格、表单与弹窗,示例完整且容易迁移到真实业务中。资源包共 18 个文件,压缩后仅 25KB,包含 5 个 ts 类型与逻辑文件、2 个 vue 单文件组件和 5 个 json 配置文件,同时提供入口 html、favicon 图标及 README 说明等,覆盖依赖管理、构建脚本、页面组件与说明文档等关键部分。目前已有 1168 人浏览学习,通过源码可以具体看到路由与 store 模块的接入方式、Vite 配置项的写法以及组件封装思路,对想快速搭建后台管理系统或深化 Vue3 工程化认识的人颇有帮助,适合作为后台管理项目起步模板或进阶学习资料。
1. 为什么 Vue3 + TypeScript + Vite + Element Plus 的后台管理源码值得自己重写一遍
我见过太多人拿到一个后台管理系统源码 zip 之后,第一件事不是读路由、不是改接口,而是先跑npm install试试能不能起来。结果要么是 Node 版本不对,要么是 Vite 创建时选了旧模板,要么是 Element Plus 的按需导入没配好,页面加载了但按钮全是空白。Vue3 + TypeScript + Vite + Element Plus 这套组合,表面上是“快速搭后台”的标准答案,真正拉开项目差距的是类型边界、动态路由、权限指令和表格封装这些细节。与其去改别人封装过度的代码,不如从零落一版自己能维护的骨架。
这篇内容面向已经会写 Vue2 或 React,想切 Vue3 的同学,也适合正在准备 vue3 面试题的人。我会把绕不开的配置、请求层、权限路由、表格封装和交付源码 zip 前的自检顺序讲清楚,每一步都可以直接抄到项目里改着用。
2. 用 Vite 搭 Vue3 + TypeScript 项目:版本、目录与 Element Plus 集成
后台管理系统源码看起来复杂,根子还是那套构建链路:Vite 负责 dev server 和打包,TypeScript 负责给数据定形状,Element Plus 提供现成的页面组件。选型上不用纠结“脚手架是不是最新的”,稳定可维护比版本激进更重要。
2.1 Vue3、TypeScript、Vite 和 Element Plus 之间怎么选版本
先把版本匹配关系定下来,很多 vue3 安装及环境配置的问题都出在这一步。Node 版本过低时 Vite 5 以上会直接给出 engine 警告,TypeScript 版本旧了则会影响defineProps的类型推断。
| 依赖 | 建议版本区间 | 说明 |
|---|---|---|
| Node.js | 18.18+ 或 20+ | Vite 5 要求 18+,建议 LTS |
| vue | ^3.4 | Composition API 差异不大,3.4 后 defineModel 等体验稳定 |
| vite | ^5.0 | 如果团队旧工程还在 4.x,不必强升 |
| typescript | ^5.4 | tsconfig 用 moduleResolution: bundler |
| element-plus | ^2.7 | 2.11.x 有个别表格阴影 bug,选 2.7+ 均可 |
注意,element-plus 的版本号不要跟 element ui 中文官网老文档混着看。Vue3 场景只认 element-plus,不要再去搜 Vue2 的 element ui 语法。
2.2 创建 Vite 项目的最小命令与 src 目录规划
用 Vite 官方脚手架拉模板时,我会直接指定 vue-ts 模板,避免手动补 TypeScript 相关文件。命令执行如下:
npm create vite@latest admin-vue3 -- --template vue-ts cd admin-vue3 npm install npm install element-plus @element-plus/icons-vue npm install -D unplugin-auto-import unplugin-vue-components第一行里的-- --template vue-ts是 npm 在向 create-vite 传参,生成的 src 下已经有vite-env.d.ts和 tsconfig 配置。后续安装 element-plus 主体、图标库,以及自动按需导入用的两个 unplugin 插件。
创建完后的 src 目录我一般会固定成下面这样,后续源码 zip 交付给你别人时也很好解释:
src/ api/ # 按模块拆接口请求 assets/ # 静态资源 components/ # 全局组件 directives/ # 自定义指令 layouts/ # 后台框架布局 router/ # 路由定义与守卫 stores/ # Pinia 状态 types/ # 全局类型定义 utils/ # 通用工具 views/ # 页面这个目录不是 Vite 强制要求的,但后台管理系统源码动态路由、权限、请求拦截都依赖清晰的归属。如果一开始就把接口写在组件内部,后面做路由懒加载和单元测试会很痛苦。
2.3 把 Element Plus 装进后台管理里:全量引入与按需导入的取舍
Element Plus 有两种常见接入方式:全量注册适合内部后台快速开发,因为不需要额外处理自动导入,缺点是首屏包体体积会大。按需导入更适合开源或对外发布的模板,用vite.config.ts配置两个 unplugin 插件即可。
import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } })上面这段配置最核心的是两个 resolver,它们会扫描代码里用到的ElButton、ElTable这类组件,并在编译时自动导入对应的样式和脚本。AutoImport 还会顺带把ElMessage、ElMessageBox这类 API 式组件处理掉,但实测ElLoading指令需要单独在组件中处理,不能完全依赖自动导入覆盖所有场景。
Element Plus 的图标处理跟组件不同。图标是注册成全局组件的,在main.ts里这样挂载最省事:
import { createApp } from 'vue' import App from './App.vue' import * as ElementPlusIconsVue from '@element-plus/icons-vue' const app = createApp(App) for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } app.mount('#app')这里使用Object.entries遍历图标库,相当于把所有图标注册为全局组件。后台管理系统中侧边栏菜单图标经常由接口动态返回,全局注册后模板里可以直接写component :is="menuIcon",不需要每次手动 import。
2.4 Vite 路径别名与接口代理配置
后台管理系统开发时最常遇到的坑是接口跨域。前端代码里写/api/user/list,请求会先落到 Vite dev server,再由 dev server 转发到后端。配置写在server.proxy里:
server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }changeOrigin必须设为 true,否则后端接口做域名校验时会拒绝请求。target 地址按后端实际服务地址调整,如果是开发环境用 https 的自签证书接口,需要再加secure: false。路径别名方面,前面配置里的'@': fileURLToPath(new URL('./src', import.meta.url))能让你在代码里写@/api/user,这比../../api/user这种相对路径可读性好得多。
3. 让 TypeScript 在后台管理系统里真正干活:类型边界、请求层与动态权限路由
很多人学 vue3 时会把 TypeScript 当成“给变量加个类型”的工具,但后台管理系统的复杂度主要在接口数据、权限状态和路由之间的联动。只有把类型定在边界上,写业务页时才能自动推导出返回结构。
3.1 用 interface 守住用户信息、表格行和接口响应
先建立一个全局通用的接口响应类型。常见后端返回格式是{ code, message, data },前端所有请求模块都基于它做泛型推导:
// src/types/api.ts export interface ApiResult<T = unknown> { code: number message: string data: T } export interface LoginParams { username: string password: string } export interface UserInfo { id: number name: string avatar?: string roles: string[] permissions: string[] }ApiResult<T>里的 T 用了泛型默认值unknown,这样写普通接口时如果漏传泛型参数,TypeScript 会要求你手动处理 data 类型,而不是返回 any。UserInfo中的 roles 和 permissions 拆开放,后续做路由权限和按钮权限时可以分别判断。
3.2 用泛型封装 Axios 请求层,统一拦截错误码
axios 在后台管理系统的职责不只是发请求,还要处理 token、错误提示和 Loading。封装时不要返回整个 response 对象,最好直接返回data,这样业务代码拿到的是真实的数据体。
import axios, { type AxiosRequestConfig } from 'axios' import { ElMessage } from 'element-plus' import type { ApiResult } from '@/types/api' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000 }) service.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( (response) => { const res = response.data as ApiResult if (res.code === 0) { return res.data } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message || 'Error')) }, (error) => { ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } ) export function request<T = unknown>(config: AxiosRequestConfig): Promise<T> { return service.request(config) }响应拦截器里将code === 0视为业务成功,其余情况直接弹出 element-plus 的ElMessage。这里最容易写错的是拦截器返回值与泛型不一致:service.request返回的是Promise<AxiosResponse>,但封装后我们希望得到Promise<T>,所以必须在一层函数里手动断言返回值。
使用这个请求层时模块化写接口,类型可以透传到页面:
// src/api/user.ts import { request } from '@/utils/request' import type { UserInfo } from '@/types/api' export function getUserInfo() { return request<UserInfo>({ url: '/user/info', method: 'get' }) }页面调用getUserInfo()时,得到的值会被推定为UserInfo,直接访问userInfo.name时会有补全。这就是 TypeScript 在后台管理里最直观的好处。
3.3 Pinia 保存用户状态,路由守卫生成动态路由
动态路由是后台管理源码中比较容易绕晕的部分。我的做法是把不需要登录就能访问的路由放进静态路由,需要权限的路由单独定义成asyncRoutes,登录后根据用户权限逐条过滤并注册。
// src/stores/user.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { UserInfo } from '@/types/api' import { getUserInfo } from '@/api/user' export const useUserStore = defineStore('user', () => { const userInfo = ref<UserInfo | null>(null) const routerLoaded = ref(false) const isLogin = computed(() => !!userInfo.value) async function fetchUserInfo() { userInfo.value = await getUserInfo() } function logout() { userInfo.value = null routerLoaded.value = false localStorage.removeItem('token') } return { userInfo, routerLoaded, isLogin, fetchUserInfo, logout } })路由守卫中注册动态路由时,最关键的是在权限不匹配时跳过整个路由,而不是把路由加进去之后再靠 beforeEach 否决:
import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '@/stores/user' import type { RouteRecordRaw } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [] }) router.beforeEach(async (to) => { const userStore = useUserStore() if (!userStore.isLogin && to.path !== '/login') { return { path: '/login', query: { redirect: to.fullPath } } } if (userStore.isLogin && !userStore.routerLoaded) { const asyncRoutes = filterAsyncRoutes( await getAsyncRoutes(), userStore.userInfo?.permissions ?? [] ) asyncRoutes.forEach((route) => router.addRoute(route)) userStore.routerLoaded = true return { ...to, replace: true } } })上面这段代码里getAsyncRoutes是远程获取的路由表,路由表里可以带meta.permission字段,filterAsyncRoutes负责递归过滤没有权限的子路由。第一次登录后注册路由,再通过replace: true重新进入原目标路由,否则刷新页面后会出现白屏。
3.4 按钮级权限用 v-permission 指令控制
菜单权限过滤完,页面上“新增”“删除”按钮还是需要单独控制。Element Plus 加 Vue3 自定义指令是比较轻的权限实现方式:
import type { Directive } from 'vue' import { useUserStore } from '@/stores/user' export const permission: Directive<HTMLElement, string> = { mounted(el, binding) { const userStore = useUserStore() const permissions = userStore.userInfo?.permissions ?? [] const required = binding.value if (required && !permissions.includes(required)) { el.parentNode?.removeChild(el) } } }在入口文件里注册指令:
app.directive('permission', permission)模板里这样使用:
<el-button v-permission="'user:create'" type="primary">新增用户</el-button>如果用户没有user:create权限,按钮会被直接移除,而不是隐藏。直接移除可以避免按钮虽然看不见但还停留在 DOM 中提交表单的问题,也可以避免权限数据变化时按钮不刷新的麻烦。
4. 用 Element Plus 做后台业务页面:搜索、列表、分页、弹窗的通用写法
到了页面层,后台管理系统最常见的形态就是“上面的搜索条件,中间的表格,下面的分页”。Element Plus 的表格功能很强,但如果每个页面都把 loading、分页参数和请求函数复制一遍,代码量会迅速膨胀。做成可复用的组合式函数才是 Vue3 推荐的方式。
4.1 一个最小可用的 Element Plus 列表页结构
先看最小可用的页面写法:
<script setup lang="ts"> import { reactive, ref, onMounted } from 'vue' import { getUserList } from '@/api/user' import type { UserInfo } from '@/types/api' const loading = ref(false) const list = ref<UserInfo[]>([]) const query = reactive({ keyword: '', page: 1, pageSize: 10, total: 0 }) async function loadList() { loading.value = true try { const data = await getUserList(query) list.value = data.records query.total = data.total } finally { loading.value = false } } onMounted(loadList) </script> <template> <el-form :inline="true" :model="query"> <el-form-item> <el-input v-model="query.keyword" clearable placeholder="请输入用户名" @keyup.enter="query.page = 1; loadList()" /> </el-form-item> <el-form-item> <el-button type="primary" @click="query.page = 1; loadList()"> 查询 </el-button> </el-form-item> </el-form> <el-table v-loading="loading" :data="list" border> <el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="name" label="用户名" /> <el-table-column prop="email" label="邮箱" /> </el-table> <el-pagination v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="query.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" @size-change="loadList" @current-change="loadList" /> </template>这段模板里的几个参数值得说明:v-loading接收 boolean 类型,用 loading 状态控制遮罩;el-pagination的layout中如果没有sizes,刷新页面后页容量下拉框不会显示;查询按钮里先重置page = 1再调接口,避免已经在第 5 页时搜索后列表变空。onMounted只在列表页加载一次,如果从弹窗关闭后需要刷新,把loadList()暴露给父组件或在弹窗确认回调里手动调用。
4.2 把重复的列表逻辑收敛成 useTable 组合式函数
多个页面重复上面的代码后,可以封装一个useTable。这一步也是 vue3 面试里常被问到的组合式 API 使用场景:
import { reactive, ref } from 'vue' export function useTable<T>( api: (params: Record<string, any>) => Promise<{ records: T[]; total: number }>, initQuery: Record<string, any> = {} ) { const list = ref<T[]>([]) const loading = ref(false) const query = reactive({ page: 1, pageSize: 10, total: 0, ...initQuery }) async function getList() { loading.value = true try { const data = await api({ ...query }) list.value = data.records query.total = data.total } finally { loading.value = false } } function resetQuery() { Object.assign(query, { page: 1, pageSize: 10, ...initQuery }) } return { list, loading, query, getList, resetQuery } }页面里的用法非常精简:
const { list, loading, query, getList, resetQuery } = useTable<UserInfo>( getUserList, { keyword: '' } )这个封装有几个关键设计:api参数用泛型约束返回结构,保证 pages 拿到的 records 和 total 是安全的;resetQuery用Object.assign一次性重置,而不是逐个字段赋值;接口调用时展开...query,防止后端接口被之后添加的额外字段打断。需要注意的是pageSize数值传给后端时,有些后端要求字段名是size或limit,这里的字段名要按后端接口标准对齐,不能照搬前端习惯。
4.3 复选框保留勾选、表格阴影这类实际坑怎么处理
Element Plus 的表格复选框在分页场景有一个经典问题:翻到第二页之后,第一页勾选的项会被清空。保留勾选需要同时设置row-key和reserve-selection:
<el-table ref="tableRef" row-key="id" :data="list" @selection-change="onSelectionChange" > <el-table-column type="selection" width="55" reserve-selection /> </el-table>如果row-key只写在el-table上而没有给复选框列加reserve-selection,翻页后依然无法保留勾选。@selection-change接收的是一个数组,这里不要想着在回调里“追加”选中的行,而是接收onSelectionChange参数后赋给一个selectedRows变量即可。
另一个在 element plus 2.11.x 版本偶尔出现的问题是表格底部出现莫名其妙的阴影。这个本质是表格高度自适应时外层容器出现了溢出,和版本强相关。如果遇到,第一选择是升级补丁,第二选择是排查是否过度使用了fixed列,尤其是右侧固定列在窄屏下会触发内部高度计算异常。临时做法是给表格外层包一层overflow: hidden,但要避免使用过深的嵌套结构,否则会把下拉弹窗裁掉。
5. 整理后台管理系统源码.zip 之前的构建自检与目录清理
源码 zip 交付最忌讳的是“能跑”和“能复现”是两回事。我收到过很多压缩包,解压后要么缺.env文件,要么node_modules打进去导致解压特别慢,要么npm run build直接报类型错误。把以下三个动作固定在交付前,能省掉大量来回沟通成本。
5.1 环境变量按 Vite 的 mode 拆分
后台管理系统至少要区分本地开发和生产构建。在项目根目录创建.env.development和.env.production,Vite 会根据启动命令自动加载:
# .env.production VITE_API_BASE_URL=/admin-api VITE_APP_TITLE=运营后台在vite.config.ts里按 mode 设置部署路径:
export default defineConfig(({ mode }) => ({ base: mode === 'production' ? '/admin/' : '/', plugins: [vue()] }))base参数影响打包后的静态资源路径。如果部署在域名根路径,base 设为/即可;如果部署在https://example.com/admin/下,base 必须写成/admin/,否则 js 和 css 会 404。
5.2 压缩源码 zip 前跑一遍类型检查
很多源码包在开发环境正常,是因为 vite 不会强制做类型检查。交付前要在package.json里确认 build 命令是否包含vue-tsc:
{ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview" } }vue-tsc -b会读取 tsconfig 并检查.vue文件内部的模板类型。如果你习惯用any跳过类型问题,这一步会成功过关;如果模板里把不存在的属性传给 Element Plus 表格列,这里大概率会报错。交付前跑一次npm run build,比反复回答“为什么我本地没问题”更有效率。
5.3 压缩目录不要带 node_modules,保留运行时必备文件
压缩源码 zip 时,至少要排除node_modules、dist、.git和.idea。.git目录会导致压缩包体积暴涨,而且会把本地分支信息和提交记录一并带出去。dist是构建产物,收源码的人不需要它。.env.local文件里如果写有真实密钥,也要重点排除。
一个相对稳妥的交付目录结构是:
admin-vue3/ src/ public/ types/ vite.config.ts tsconfig.json package.json package-lock.json .env.development .env.production README.md保留package-lock.json可以锁定依赖版本,避免对方安装时拉到不兼容的更新版本。README 里至少写清楚 Node 版本、安装命令、启动命令、代理目标地址和构建命令,这几项写完后,源码 zip 不需要额外解释就能自己跑起来。最后重新解压一份副本,在干净的临时目录执行npm install && npm run dev,确认没有本地绝对路径后,再发出去。
本文还有配套的精品资源,点击获取