第10章 10.2 终极实战:企业级中后台
—— 90分钟,把前44章的知识串起来,做一个真正能跑的管理后台
🎯 开场 3 分钟:为什么要学这个?
上一章我们聊了 Vue 3 的新特性展望,你知道了 Composition API 是什么、setup 糖好不好用、TypeScript 支持到哪个程度了。感觉学了一大堆概念,但——
老实说,你现在能独立写一个完整项目吗?
我见过太多学员了:学 Vue 指令很 6,学组件通信很溜,学 Pinia 状态管理也没问题,但一让他从零开始搭一个完整的项目,就懵了:
- 文件夹怎么组织?src 下面放啥?
- API 请求放哪里?每个组件里写一遍?
- 登录状态怎么管理?刷新页面丢了怎么办?
- 权限怎么控制?普通用户能看到管理员按钮吗?
这些问题,前 44 章没一个会教你。
因为那些是「知识点」,而这一章是「串联」。就像你学做饭,分别学了切菜、开火、调味,但没人教过你怎么把它们串起来做一盘能吃的菜。
这一章,就是那盘菜。
学完你能解决\n\n
\n\n
\n\n:从零搭建一个 Vue 3 企业中后台,包含登录鉴权、权限控制、数据增删改查、状态持久化。不是玩具,是真的能放到简历里的那种。
🧱 基础 25 分钟:核心概念(小白视角)
项目结构:你的厨房布局
盖房子前先看图纸。学 Vue 项目也一样,文件夹怎么摆,决定了代码怎么写。
一个标准的企业中后台项目,长这样:
my-admin/
├── src/
│ ├── api/ # 所有接口请求,像外卖订单窗口
│ ├── assets/ # 图片、字体等资源
│ ├── components/ # 公共组件,像调料瓶谁都能用
│ ├── layout/ # 页面骨架,房子的承重墙
│ ├── router/ # 路由配置,像导航地图
│ ├── stores/ # 状态管理,像冰箱里的食材
│ ├── utils/ # 工具函数,像菜刀砧板
│ ├── views/ # 页面,像不同的房间
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
├── package.json
└── vite.config.js
类比一下:
- api/ 是外卖窗口,你从这里「点单」(发请求)
- stores/ 是冰箱,食材(数据)放这儿,全厨房(整个应用)都能用
- router/ 是导航地图,告诉用户每条路通向哪里
- views/ 是各个房间,每个房间有不同功能
Pinia 状态管理:你的智能冰箱
第 44 章我们简单提过 Pinia,但没细说。Pinia 是什么?
说白了就是:一个帮你管理全局数据的东西。
举个例子:你登录了一个系统,用户名、头像、权限这些信息,不属于任何一个具体页面,但每个页面都要用。放哪儿?
- 放组件里?A 页面改了,B 页面不知道
- 放 localStorage?每次都要读写,烦死了
Pinia 就是那个「智能冰箱」:你把东西放进去,全 App 共享;你改了东西,所有用这个东西的地方自动更新。
// stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
// 状态
const token = ref('')
const userInfo = ref(null)
// 方法
function login(data) {
token.value = data.token
userInfo.value = data.userInfo
}
function logout() {
token.value = ''
userInfo.value = null
}
return { token, userInfo, login, logout }
})
这3行在干嘛:
- ref('') 创建了一个响应式变量,像冰箱里的一个空盒子
- login() 把登录数据存进去
- logout() 清空数据
Axios 请求:你的外卖下单系统
前端不存数据,数据在后端服务器里。怎么拿?发请求。
Axios 就是帮你发请求的工具。类比一下:
你(前端) → 点外卖APP(Axios) → 餐厅(后端API)
一个典型的 API 请求长这样:
// api/user.js
import axios from 'axios'
// 创建 axios 实例
const request = axios.create({
baseURL: '/api', // 基础地址
timeout: 5000 // 等5秒不要我就放弃
})
// 请求拦截器:发之前做点事
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:回来之后做点事
request.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
// 没登录,跳登录页
location.href = '/login'
}
return Promise.reject(error)
}
)
export default request
这3段在干嘛:
- axios.create() 创建了一个「外卖下单系统」,配置好了地址和超时
- request.interceptors.request 在发请求前偷偷塞 token
- request.interceptors.response 在收到响应后自动处理 401(没权限)
路由守卫:你的门卫保安
不是所有人都能进所有房间。路由守卫就是门卫:有人来,问一句「你有证件吗?」
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true } // 需要登录
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/Admin.vue'),
meta: { requiresAuth: true, requiresAdmin: true } // 需要管理员
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 门卫核心:每次路由跳转前检查
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.token) {
// 要进需要登录的页面,但没token,滚去登录
next('/login')
} else if (to.meta.requiresAdmin && userStore.userInfo?.role !== 'admin') {
// 要进管理员页面,但不是管理员,滚去首页
next('/dashboard')
} else {
// 都有证件,过
next()
}
})
export default router
这3个判断在干嘛:
- requiresAuth && !userStore.token → 想进需要登录的页面,但没登录?去登录
- requiresAdmin && role !== 'admin' → 想进管理员页面,但你是普通用户?去首页
- 都没问题?放行
权限控制:不同的钥匙开不同的门
光有门卫不够,有些人能看到管理员按钮,有些人不能。v-permission 指令就是「隐形钥匙」:
// directives/permission.js
export default {
install(app) {
app.directive('permission', (el, binding) => {
const userStore = useUserStore()
const permissions = userStore.userInfo?.permissions || []
if (!permissions.includes(binding.value)) {
// 没有这个权限,把按钮删掉
el.parentNode?.removeChild(el)
}
})
}
}
然后在模板里这样用:
<template>
<el-button v-permission="'user:delete'">删除用户</el-button>
<!-- 没有 user:delete 权限的人看不到这个按钮 -->
</template>
🔥 实战 35 分钟:3个递进的小项目
项目1(5分钟):登录 + Token 持久化
跟着抄就能跑,理解核心流程:登录 → 存 token → 能访问需要登录的页面。
<!-- views/Login.vue -->
<template>
<div class="login-container">
<h2>管理员登录</h2>
<el-form :model="form" style="width: 300px">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="admin" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="123456" />
</el-form-item>
<el-button type="primary" @click="handleLogin" :loading="loading">
登录
</el-button>
</el-form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import request from '@/api/request'
const router = useRouter()
const userStore = useUserStore()
const form = ref({ username: '', password: '' })
const loading = ref(false)
const handleLogin = async () => {
loading.value = true
try {
const res = await request.post('/login', form.value)
// 登录成功,存token和用户信息
userStore.login(res)
// 存一份到 localStorage,刷新页面不丢
localStorage.setItem('token', res.token)
localStorage.setItem('userInfo', JSON.stringify(res.userInfo))
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (err) {
ElMessage.error('用户名或密码错误')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 100px;
}
</style>
预期输出:输入 admin / 123456 → 点击登录 → 跳转 /dashboard 并显示成功提示
这3个步骤在干嘛:
- userStore.login(res) 把数据存到 Pinia(内存)
- localStorage.setItem() 把数据同步存到本地(刷新不丢)
- router.push('/dashboard') 跳转到主页
项目2(15分钟):用户列表 + 增删改查
从 API 读取用户列表,展示在表格里,支持搜索、分页、编辑、删除。
<!-- views/UserManage.vue -->
<template>
<div class="user-manage">
<h2>用户管理</h2>
<!-- 搜索栏 -->
<el-form inline>
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="搜索用户名" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 用户表格 -->
<el-table :data="tableData" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="role" label="角色">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'">
{{ row.role === 'admin' ? '管理员' : '普通用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'warning'">
{{ row.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(row)"
v-permission="'user:delete'"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="fetchUsers"
@current-change="fetchUsers"
style="margin-top: 20px; justify-content: center"
/>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" title="编辑用户" width="500px">
<el-form :model="editForm" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="editForm.username" disabled />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="editForm.email" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="editForm.role">
<el-option label="管理员" value="admin" />
<el-option label="普通用户" value="user" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="editForm.status" active-value="active" inactive-value="inactive" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/api/request'
// 表格数据
const tableData = ref([])
const pagination = ref({ page: 1, size: 10, total: 0 })
const searchForm = ref({ username: '' })
// 编辑相关
const dialogVisible = ref(false)
const editForm = ref({ id: '', username: '', email: '', role: 'user', status: 'active' })
// 获取用户列表
const fetchUsers = async () => {
try {
const res = await request.get('/users', {
params: {
page: pagination.value.page,
size: pagination.value.size,
username: searchForm.value.username
}
})
tableData.value = res.list
pagination.value.total = res.total
} catch (err) {
ElMessage.error('获取用户列表失败')
}
}
// 搜索
const handleSearch = () => {
pagination.value.page = 1
fetchUsers()
}
const handleReset = () => {
searchForm.value.username = ''
handleSearch()
}
// 编辑
const handleEdit = (row) => {
editForm.value = { ...row }
dialogVisible.value = true
}
const handleSave = async () => {
try {
await request.put(`/users/${editForm.value.id}`, editForm.value)
ElMessage.success('保存成功')
dialogVisible.value = false
fetchUsers()
} catch (err) {
ElMessage.error('保存失败')
}
}
// 删除
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(`确定删除用户 ${row.username} 吗?`, '提示', {
type: 'warning'
})
await request.delete(`/users/${row.id}`)
ElMessage.success('删除成功')
fetchUsers()
} catch {
// 用户取消了
}
}
onMounted(fetchUsers)
</script>
预期输出:
- 页面加载后显示用户表格,有分页
- 点击「编辑」弹出对话框,修改后保存成功
- 点击「删除」弹出确认框,确认后删除成功
- 没有 user:delete 权限的用户,看不到删除按钮
这3个功能在干嘛:
- fetchUsers() 从后端拿数据,绑定到表格
- handleSave() 把修改提交到后端,刷新表格
- handleDelete() 删除数据,重新加载表格
项目3(15分钟):把登录和用户管理串起来
组合前两个项目,加一个刷新页面不丢登录状态的功能。
// stores/user.js 增强版
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
function login(data) {
token.value = data.token
userInfo.value = data.userInfo
// 自动同步到 localStorage
localStorage.setItem('token', data.token)
localStorage.setItem('userInfo', JSON.stringify(data.userInfo))
}
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
return { token, userInfo, login, logout }
})
再给 App.vue 加一个全局状态展示:
<!-- App.vue -->
<template>
<div id="app">
<!-- 已登录才显示顶栏 -->
<el-header v-if="userStore.token" class="app-header">
<span>欢迎,{{ userStore.userInfo?.username }}</span>
<el-button size="small" @click="handleLogout">退出登录</el-button>
</el-header>
<el-main>
<router-view />
</el-main>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const userStore = useUserStore()
const router = useRouter()
const handleLogout = () => {
userStore.logout()
ElMessage.success('已退出登录')
router.push('/login')
}
</script>
<style>
.app-header {
display: flex;
justify-content: flex-end;
align-items: center;
background: #409eff;
color: white;
padding: 0 20px;
}
</style>
预期输出:
- 登录后刷新页面,顶部依然显示用户名和退出按钮
- 点击退出,返回登录页,刷新后依然在登录页
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑1:Pinia 在 setup 外使用
❌ 错误:
// 路由守卫里这样用会报错
router.beforeEach((to, from, next) => {
const userStore = useUserStore() // 报错!setup 外不能用
next()
})
✅ 正确:
// 路由守卫应该这样写
router.beforeEach((to, from, next) => {
// 在 setup 上下文里才能用 useUserStore
// 所以要把路由守卫逻辑放到组件的 setup 里
next()
})
更好的做法:在 Pinia 里写一个 setupAuth 方法:
// stores/user.js
export const useUserStore = defineStore('user', () => {
// ...前面省略
// 专门给路由守卫用的初始化方法
function initFromStorage() {
token.value = localStorage.getItem('token') || ''
userInfo.value = JSON.parse(localStorage.getItem('userInfo') || 'null')
}
return { token, userInfo, login, logout, initFromStorage }
})
坑2:Token 过期没处理
❌ 错误:Token 过期了,用户还在傻傻操作,不知道为什么失败
✅ 正确:在 axios 拦截器里统一处理 Token 过期:
request.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
// Token 过期了,清状态,跳登录
const userStore = useUserStore()
userStore.logout()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
}
return Promise.reject(error)
}
)
坑3:直接在组件里发请求
❌ 错误:每个组件里写一堆 axios 代码,重复又难维护
✅ 正确:统一封装到 api 文件夹:
// api/user.js
import request from './request'
export const userApi = {
login: (data) => request.post('/login', data),
getList: (params) => request.get('/users', { params }),
update: (id, data) => request.put(`/users/${id}`, data),
delete: (id) => request.delete(`/users/${id}`)
}
// 组件里这样用
import { userApi } from '@/api/user'
const res = await userApi.getList({ page: 1, size: 10 })
坑4:用 any 代替类型检查
❌ 错误:图方便,所有地方写 any,TypeScript 形同虚设
✅ 正确:用接口定义数据结构:
// types/user.ts
export interface UserInfo {
id: number
username: string
email: string
role: 'admin' | 'user'
status: 'active' | 'inactive'
permissions: string[]
}
export interface LoginForm {
username: string
password: string
}
坑5:刷新页面丢状态
❌ 错误:只存 Pinia,刷新页面数据全没了
✅ 正确:Pinia + localStorage 双写(见项目3)
性能小贴士:路由懒加载
不用 () => import() 的话,所有页面代码会打包到一个巨大的 JS 文件,首屏加载超慢。
// 错误:一次性加载所有页面
import Dashboard from '@/views/Dashboard.vue'
// 正确:访问时才加载
component: () => import('@/views/Dashboard.vue')
调试技巧:Console 日志分组
const fetchUsers = async () => {
console.group('获取用户列表')
console.log('请求参数:', pagination.value)
try {
const res = await request.get('/users', { params: pagination.value })
console.log('响应数据:', res)
tableData.value = res.list
console.groupEnd()
} catch (err) {
console.error('请求失败:', err)
console.groupEnd()
}
}
✏️ 练习题 + 作业题
练习题(5道,10分钟内完成)
练习1(2分钟):添加一个「新增用户」功能
- 输入:在 UserManage.vue 的表格上方加一个「新增用户」按钮
- 预期输出:点击后弹出对话框,可填写用户名、邮箱、角色
- 提示:复制 handleEdit 和 dialogVisible,改一改就行
练习2(2分钟):给搜索框加个「回车搜索」
- 输入:在搜索用户名 input 上按回车
- 预期输出:触发搜索,和点按钮效果一样
- 提示:加一个 @keyup.enter 事件
练习3(3分钟):处理空列表
- 输入:用户列表为空时
- 预期输出:显示「暂无数据」提示(Element Plus 表格自带)
- 提示:el-table 不用改,检查 API 返回空数组就行
练习4(5分钟):把练习1和练习2串起来
- 输入:新增用户后,表格自动刷新
- 预期输出:保存成功后,fetchUsers() 被调用
- 提示:在保存成功的回调里加一行 fetchUsers()
练习5(3分钟):分析这个报错
- 输入:用户登录后刷新页面,发现顶部没显示用户名
- 预期输出:说出可能的原因(3个)
- 提示:检查 localStorage 有没有、Pinia 有没有初始化、App.vue 的 v-if 条件
作业题(30分钟-2小时)
作业:做一个「图书管理系统」
需求描述:
做一个图书馆后台管理页面,能管理图书信息。
功能点:
1. 图书列表展示(表格、分页、搜索书名)
2. 新增图书(书名、作者、分类、库存)
3. 编辑图书(修改库存数量)
4. 借阅功能(点按钮减少库存)
加分项:
1. 用 TypeScript 写,定义好接口类型
2. 实现「借阅记录」Tab,查看谁借了哪本书
验收标准:
- 能跑起来
- 图书增删改查正常工作
- 借阅后库存自动减少
- 代码有注释
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本章核心3点
- 项目结构决定代码组织:api/stores/views/router 各司其职,别乱放
- Pinia + localStorage = 状态持久化:刷新不丢登录态的核心配方
- axios 拦截器 = 统一处理:Token 注入、错误处理只写一次,全局生效
延伸学习资源
- 官方文档:Pinia 文档 / Vue Router 文档
- 视频教程:B 站「技术蛋」Vue3 实战系列
- 项目参考:GitHub 搜索
vue3-admin/vue-element-admin,学习别人的项目结构
互动钩子
「你的第一个 Vue 项目是怎么搭起来的?遇到过什么坑?」
评论区聊聊,老粉优先回复!抽 3 个典型问题下周单独解答。
🎉 恭喜你完成了 Vue3 从入门到精通全部 45 章!
现在你手里有:Vue 基础 + 组件化 + Composition API + TypeScript + Vite + Pinia + 路由 + 权限 + 实战经验。这套组合拳,打哪个公司都够用了。
下一步建议:找个感兴趣的 API(天气、新闻、地图都行),做一个自己的小项目。看教程 100 遍,不如自己动手做一遍。
我们下个项目见! 🚀

评论(0)