实战:管理后台项目
建筑师不会在画完蓝图后才第一次拿起锤子。真正的理解来自建造——把钢筋、混凝土和管线在三维空间里协调到位。前面八章,你已经掌握了 Vue 3 从模板语法到工程化实践的全部核心知识。现在,我们要把这些散落的零件组装成一台完整的机器:一个在线教育管理平台的后台系统。
📋 开篇自测
在动手之前,先检验你的知识储备:
- 如果一个后台系统有 20 个页面,但它们共享同一套侧边栏和顶部工具栏,你会把布局代码放在哪里?为什么不在每个页面里都写一份?
- 用户登录后获取的 Token 应该存储在哪里?当 Token 过期时,前端应该如何优雅地引导用户重新登录?
- 当你有「课程管理」「讲师管理」「学员管理」三个页面,表格结构几乎一样,你会为每个页面写一套独立的 Table 代码,还是会抽象出通用组件?你的判断依据是什么?
如果这三个问题你能清晰作答,可以选读感兴趣的小节;否则,请跟随本章逐一攻破。
一、项目规划与架构设计
1.1 需求分析:在线教育管理平台
假设你接到了一个真实需求:为「星辰在线教育」搭建管理后台。产品经理给出的核心功能清单如下:
- 数据看板:展示今日新增学员数、课程销售额、活跃用户数等核心指标,并配有趋势图表
- 课程管理:课程列表的增删改查,支持搜索、分页、批量操作
- 学员管理:学员信息浏览与筛选
- 讲师管理:讲师信息的维护
- 登录鉴权:管理员登录、Token 鉴权、路由权限控制
拿到需求后,很多初学者会立刻打开编辑器开始写代码。但经验丰富的工程师会先做一件事——架构设计。这就像盖楼之前先打地基、画结构图:地基歪了,楼层再高也撑不住。
1.2 技术选型
结合第八章的工程化知识,我们做出以下技术决策:
| 层面 | 选型 | 理由 |
|---|---|---|
| 构建工具 | Vite | 毫秒级冷启动,原生 ESM 按需编译 |
| UI 框架 | Element Plus | Vue 3 生态最成熟的中后台组件库 |
| 路由 | Vue Router 4 | 官方路由方案,支持组合式 API |
| 状态管理 | Pinia | 轻量、类型友好,已成为官方推荐 |
| HTTP 客户端 | Axios | 拦截器机制成熟,便于统一错误处理 |
| 图表 | ECharts 5 | 功能全面,社区活跃 |
1.3 目录结构设计
关于 JavaScript vs TypeScript 的说明:本章实战项目使用 JavaScript 编写,目的是聚焦于项目架构和业务逻辑,降低示例代码的理解门槛。在实际的团队项目中,我们强烈建议参照第 8 章的 TypeScript 集成方案,为项目添加类型支持——特别是 API 响应类型、Store 状态类型和组件 Props 类型,这些是 TypeScript 带来收益最大的地方。
一个清晰的目录结构,是团队协作的无声契约。每个文件夹的位置就像图书馆的分类索引——任何人打开项目,都能在三秒内找到目标文件。
edu-admin/
├── public/ # 静态资源(不经过构建)
├── src/
│ ├── api/ # 接口请求函数(按模块拆分)
│ │ ├── course.js # 课程相关 API
│ │ ├── student.js # 学员相关 API
│ │ └── instructor.js # 讲师相关 API
│ ├── assets/ # 图片、字体等静态资源
│ ├── components/ # 全局通用组件
│ │ ├── AppHeader.vue # 顶部工具栏
│ │ ├── AppSidebar.vue # 侧边导航栏
│ │ └── DialogForm.vue # 通用弹窗表单
│ ├── composables/ # 组合式函数
│ │ ├── useAuth.js # 鉴权逻辑
│ │ └── usePagination.js # 分页逻辑
│ ├── layouts/ # 布局组件
│ │ └── AdminLayout.vue # 后台主布局
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── stores/ # Pinia 状态仓库
│ │ └── user.js
│ ├── styles/ # 全局样式
│ │ └── variables.scss # 主题变量覆盖
│ ├── utils/ # 工具函数
│ │ ├── request.js # Axios 二次封装
│ │ └── storage.js # 本地存储封装
│ ├── views/ # 页面组件(按功能模块)
│ │ ├── dashboard/
│ │ ├── course/
│ │ ├── student/
│ │ ├── instructor/
│ │ └── login/
│ ├── App.vue
│ └── main.js
├── index.html
├── vite.config.js
└── package.json
这套结构遵循一条设计原则:按职责分层,按业务分模块。api/ 目录只放接口函数,composables/ 只放可复用的组合式逻辑,views/ 按业务模块分文件夹——这样无论项目长到多大,新成员也能快速定位代码。
1.4 初始化项目
npm create vite@latest edu-admin -- --template vue
cd edu-admin
npm install
npm install vue-router@4 pinia axios element-plus echarts
npm install -D sass unplugin-vue-components unplugin-auto-import
配置 Vite,完成路径别名、代理和 Element Plus 按需导入:
关于本章代码风格: 为了降低实战门槛、聚焦业务逻辑本身,本章示例统一使用
.js后缀。在实际项目中,推荐按照第八章的工程化规范使用 TypeScript(.ts/.vue+<script setup lang="ts">),以获得更好的类型安全和 IDE 支持。
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: 'sass' })],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`,
},
},
},
server: {
proxy: {
'/edu-api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/edu-api/, ''),
},
},
},
})
通过 unplugin-vue-components 和 unplugin-auto-import 实现 Element Plus 组件和 API 的自动按需导入——你在模板中使用 <el-button> 时无需手动 import,构建工具会自动完成。additionalData 配置则允许你用自定义的 SCSS 变量覆盖 Element Plus 的默认主题色。
在 src/styles/variables.scss 中自定义主题:
// src/styles/variables.scss
@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
"primary": (
"base": #4f46e5,
),
),
);
这样整个系统的主色调将统一为教育品牌的靛蓝色,而非 Element Plus 默认的蓝色。
二、布局系统
2.1 经典后台布局:左侧固定 + 右侧自适应
后台管理系统的布局就像一本打开的书:左侧是目录(侧边栏),右侧是正文(内容区)。正文又分为书眉(顶部工具栏)、正文主体和页脚。这种布局之所以成为事实标准,是因为它在信息层级和操作效率之间取得了最佳平衡。
<!-- src/layouts/AdminLayout.vue -->
<template>
<el-container class="admin-layout">
<el-aside class="admin-sidebar" :width="sidebarWidth">
<AppSidebar :collapsed="isCollapsed" />
</el-aside>
<el-container class="admin-main">
<el-header class="admin-header">
<AppHeader
:collapsed="isCollapsed"
@toggle-sidebar="isCollapsed = !isCollapsed"
/>
</el-header>
<el-main class="admin-content">
<router-view />
</el-main>
<el-footer class="admin-footer" height="48px">
<span>© 2026 星辰在线教育 · 管理后台 v1.0.0</span>
</el-footer>
</el-container>
</el-container>
</template>
<script setup>
import { ref, computed } from 'vue'
import AppSidebar from '@/components/AppSidebar.vue'
import AppHeader from '@/components/AppHeader.vue'
const isCollapsed = ref(false)
const sidebarWidth = computed(() => (isCollapsed.value ? '64px' : '220px'))
</script>
<style lang="scss" scoped>
.admin-layout {
min-height: 100vh;
}
.admin-sidebar {
background-color: #1d2130;
transition: width 0.3s ease;
overflow: hidden;
}
.admin-main {
display: flex;
flex-direction: column;
}
.admin-header {
height: 56px;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
padding: 0 20px;
background: #fff;
}
.admin-content {
flex: 1;
padding: 20px;
background-color: #f5f5f5;
overflow-y: auto;
}
.admin-footer {
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e8e8e8;
color: #999;
font-size: 13px;
}
</style>
注意几个关键设计决策:
el-aside的宽度通过computed响应式绑定,配合transition实现侧边栏折叠动画- 右侧内容区使用
flex-direction: column,让 header、content、footer 垂直排列 admin-content设置flex: 1和overflow-y: auto,确保内容超出时只有中间部分滚动,顶部和底部始终可见
2.2 侧边栏导航
侧边栏是整个后台的「交通枢纽」。我们通过 el-menu 的 router 属性将菜单项与路由绑定,点击菜单时自动触发路由跳转:
<!-- src/components/AppSidebar.vue -->
<template>
<div class="sidebar-logo">
<img src="@/assets/logo.svg" alt="logo" class="logo-icon" />
<span v-show="!collapsed" class="logo-text">星辰教育</span>
</div>
<el-menu
:default-active="currentRoute"
:collapse="collapsed"
:router="true"
background-color="#1d2130"
text-color="rgba(255, 255, 255, 0.7)"
active-text-color="#4f46e5"
>
<el-menu-item index="/dashboard">
<el-icon><DataAnalysis /></el-icon>
<span>数据看板</span>
</el-menu-item>
<el-sub-menu index="course-group">
<template #title>
<el-icon><Reading /></el-icon>
<span>教学管理</span>
</template>
<el-menu-item index="/course">课程管理</el-menu-item>
<el-menu-item index="/instructor">讲师管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="user-group">
<template #title>
<el-icon><User /></el-icon>
<span>用户管理</span>
</template>
<el-menu-item index="/student">学员管理</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { DataAnalysis, Reading, User } from '@element-plus/icons-vue'
defineProps({
collapsed: Boolean,
})
const route = useRoute()
const currentRoute = computed(() => route.path)
</script>
<style lang="scss" scoped>
.sidebar-logo {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.logo-icon {
width: 32px;
height: 32px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
</style>
el-menu 的 :router="true" 会在点击菜单项时,自动以该项的 index 值作为路径进行 router.push。default-active 绑定当前路由路径,保证刷新页面后菜单高亮状态不丢失。
2.3 顶部工具栏与面包屑
顶部工具栏承载两个核心功能:面包屑导航告诉用户「你在哪」,右侧操作区提供个人信息和退出入口。
<!-- src/components/AppHeader.vue -->
<template>
<div class="header-left">
<el-icon class="collapse-btn" @click="$emit('toggle-sidebar')">
<Fold v-if="!collapsed" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="item in breadcrumbs"
:key="item.path"
:to="item.path"
>
{{ item.meta?.title || item.name }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown trigger="click" @command="handleCommand">
<span class="user-info">
<el-avatar :size="28" icon="UserFilled" />
<span class="username">{{ userStore.nickname }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Fold, Expand, ArrowDown } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
defineProps({
collapsed: Boolean,
})
defineEmits(['toggle-sidebar'])
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const breadcrumbs = computed(() => {
return route.matched.filter((item) => item.meta?.title)
})
const handleCommand = (command) => {
if (command === 'logout') {
userStore.logout()
router.replace('/login')
}
}
</script>
<style lang="scss" scoped>
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #666;
&:hover {
color: #4f46e5;
}
}
.header-right {
margin-left: auto;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.username {
font-size: 14px;
color: #333;
}
</style>
面包屑数据直接来源于 route.matched——Vue Router 会把当前路径匹配到的所有层级路由记录都放进这个数组。只要我们在路由配置中设置好 meta.title,面包屑就能自动生成,无需手动维护。
三、登录与鉴权
3.1 Axios 二次封装:请求的「安检通道」
在真正写登录页面之前,我们先搭建基础设施。HTTP 请求的二次封装就像机场的安检通道——所有进出的旅客(请求和响应)都要经过统一检查。
// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getToken, removeToken } from './storage'
import router from '@/router'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE || '/edu-api',
timeout: 15000,
})
// 请求拦截器:每次出发前,把「通行证」别在胸口
service.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器:回来时检查「行李」是否正常
service.interceptors.response.use(
(response) => {
const { code, data, message } = response.data
if (code === 200) {
return data
}
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message))
},
(error) => {
if (error.response?.status === 401) {
ElMessage.warning('登录已过期,请重新登录')
removeToken()
router.replace('/login')
} else {
ElMessage.error(error.message || '网络异常')
}
return Promise.reject(error)
}
)
export default service
// src/utils/storage.js
const TOKEN_KEY = 'edu_admin_token'
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token)
}
export function removeToken() {
localStorage.removeItem(TOKEN_KEY)
}
这里有一个细节值得注意:我们使用 axios.create 创建独立实例,而非直接修改 axios.defaults。这样做的好处是,如果未来项目需要对接多个后端服务(比如教育 API 和支付 API 使用不同的域名),每个服务可以有自己独立配置的 Axios 实例,互不干扰。
响应拦截器中对 401 状态码的处理是全局兜底策略:无论用户在哪个页面,只要 Token 失效,就统一清除本地凭证并跳转到登录页。这比在每个 API 调用处单独判断高效得多。
3.2 登录表单
登录页面是用户接触后台系统的第一个界面。它不需要侧边栏和顶部工具栏,因此要独立于主布局之外。
<!-- src/views/login/LoginPage.vue -->
<template>
<div class="login-wrapper">
<div class="login-card">
<div class="login-header">
<img src="@/assets/logo.svg" alt="logo" class="login-logo" />
<h2 class="login-title">星辰教育管理平台</h2>
<p class="login-subtitle">Education Administration System</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-position="top"
class="login-form"
>
<el-form-item label="管理员账号" prop="username">
<el-input
v-model.trim="loginForm.username"
placeholder="请输入账号"
:prefix-icon="UserFilled"
size="large"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model.trim="loginForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="isLoading"
class="login-btn"
@click="handleLogin"
>
{{ isLoading ? '登录中...' : '登 录' }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { UserFilled, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const loginFormRef = ref(null)
const isLoading = ref(false)
const loginForm = reactive({
username: '',
password: '',
})
const loginRules = {
username: [
{ required: true, message: '请输入管理员账号', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不少于 6 位', trigger: 'blur' },
],
}
const handleLogin = async () => {
const valid = await loginFormRef.value.validate().catch(() => false)
if (!valid) return
isLoading.value = true
try {
await userStore.login(loginForm.username, loginForm.password)
ElMessage.success('登录成功')
router.replace('/dashboard')
} catch (err) {
ElMessage.error(err.message || '登录失败,请检查账号密码')
} finally {
isLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 420px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
width: 64px;
height: 64px;
margin-bottom: 12px;
}
.login-title {
font-size: 24px;
color: #1d2130;
margin: 0;
}
.login-subtitle {
font-size: 13px;
color: #999;
margin-top: 4px;
}
.login-btn {
width: 100%;
}
</style>
几个值得关注的实现细节:
@keyup.enter让用户在密码框按回车即可提交,提升操作效率show-password让密码框出现一个小眼睛图标,用户可以切换密码明文显示- 表单验证使用
async/await配合.catch(() => false),避免 Promise rejection 被吞掉 finally块确保无论成功失败,loading 状态都会被重置
3.3 用户状态管理
将登录态集中到 Pinia Store 中管理,所有需要用户信息的地方从 Store 读取,避免多处重复请求:
// src/stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import request from '@/utils/request'
import { setToken, removeToken } from '@/utils/storage'
export const useUserStore = defineStore('user', () => {
const nickname = ref('')
const role = ref('')
async function login(username, password) {
const { token } = await request.post('/auth/login', {
username,
password,
})
setToken(token)
await fetchProfile()
}
async function fetchProfile() {
const profile = await request.get('/auth/profile')
nickname.value = profile.nickname
role.value = profile.role
}
function logout() {
nickname.value = ''
role.value = ''
removeToken()
}
return { nickname, role, login, fetchProfile, logout }
})
login 方法在拿到 Token 后立即调用 fetchProfile 获取用户信息。这样在跳转到主页时,头部的昵称就已经准备好了,不会出现闪烁。
3.4 路由守卫:门禁系统
路由守卫是整个鉴权体系的最后一道防线。它的工作方式类似大楼的门禁系统:没有门禁卡的人只能进入大堂(登录页),持卡人员才能进入各楼层(业务页面)。
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/utils/storage'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginPage.vue'),
meta: { title: '登录', public: true },
},
{
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/DashboardPage.vue'),
meta: { title: '数据看板' },
},
{
path: 'course',
name: 'CourseList',
component: () => import('@/views/course/CourseList.vue'),
meta: { title: '课程管理' },
},
{
path: 'student',
name: 'StudentList',
component: () => import('@/views/student/StudentList.vue'),
meta: { title: '学员管理' },
},
{
path: 'instructor',
name: 'InstructorList',
component: () => import('@/views/instructor/InstructorList.vue'),
meta: { title: '讲师管理' },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to, from, next) => {
// 设置页面标题
document.title = `${to.meta.title || ''} - 星辰教育管理平台`
const token = getToken()
if (to.meta.public) {
// 公开页面(登录页):已登录则跳转首页
token ? next('/dashboard') : next()
return
}
if (!token) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// 已有 Token 但 Store 中无用户信息,尝试恢复
const userStore = useUserStore()
if (!userStore.nickname) {
try {
await userStore.fetchProfile()
} catch {
userStore.logout()
next('/login')
return
}
}
next()
})
export default router
路由配置中有两个重要模式值得说明:
第一,嵌套路由实现布局隔离。 所有业务页面都是 AdminLayout 的子路由,天然共享侧边栏和顶部工具栏。而登录页作为顶层路由,和 AdminLayout 平级,自然不会显示任何导航元素。这比在 App.vue 中用 v-if 判断路径来控制菜单显隐要优雅得多。
第二,路由懒加载。 每个页面组件都使用 () => import(...) 动态导入,Vite 会自动将它们拆分成独立的 JS 文件(code splitting)。用户在首次打开登录页时,不会下载课程管理等页面的代码,减少首屏加载量。
3.5 权限指令(进阶)
有时我们需要在按钮级别控制权限——比如只有超级管理员才能看到「删除」按钮。
推荐方案:使用 v-if 配合权限判断函数。 这是最安全的方式,因为未授权的元素根本不会被渲染到 DOM 中,用户无法通过 DevTools 恢复:
<el-button v-if="hasPermission('admin')" type="danger">删除课程</el-button>
// src/composables/usePermission.js
import { useUserStore } from '@/stores/user'
export function usePermission() {
const userStore = useUserStore()
const hasPermission = (role) => userStore.role === role
return { hasPermission }
}
如果你更倾向于用指令的方式来保持模板简洁,也可以封装一个自定义指令。但需要注意:指令方式通过操作 DOM 来隐藏元素,安全性低于 v-if(用户可通过 DevTools 恢复被隐藏���元素),仅适合做 UI 层面的辅助控制:
// src/directives/permission.js
import { useUserStore } from '@/stores/user'
export const vPermission = {
// 注意:自定义指令的钩子不在 setup() 上下文中执行,
// 此处调用 useUserStore() 依赖于 Pinia 已全局安装
mounted(el, binding) {
const userStore = useUserStore()
const requiredRole = binding.value
if (userStore.role !== requiredRole) {
el.parentNode?.removeChild(el)
}
},
}
⚠️ 安全提醒:无论使用哪种前端权限控制方式,都只能作为 UI 层面的体验优化。真正的权限校验必须在服务端 API 中完成——前端隐藏按钮不等于用户无法调用对应接口。
四、数据看板
4.1 数据卡片:一目了然的核心指标
数据看板是管理员登录后看到的第一个页面,它需要在几秒钟内传递最关键的业务信息。我们用一排数据卡片展示核心指标:
<!-- src/views/dashboard/DashboardPage.vue -->
<template>
<div class="dashboard">
<el-row :gutter="20" class="stat-cards">
<el-col :span="6" v-for="item in statCards" :key="item.label">
<el-card shadow="hover" class="stat-card">
<div class="stat-icon" :style="{ backgroundColor: item.bgColor }">
<el-icon :size="28" :color="item.iconColor">
<component :is="item.icon" />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="chart-section">
<el-col :span="16">
<el-card shadow="hover">
<template #header>
<span class="card-title">学员增长趋势</span>
</template>
<div ref="enrollmentChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<template #header>
<span class="card-title">课程类型分布</span>
</template>
<div ref="categoryChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, markRaw } from 'vue'
import * as echarts from 'echarts/core'
import { LineChart, PieChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { UserFilled, Reading, TrendCharts, Money } from '@element-plus/icons-vue'
echarts.use([
LineChart, PieChart,
TitleComponent, TooltipComponent, LegendComponent, GridComponent,
CanvasRenderer,
])
const statCards = reactive([
{
label: '今日新增学员',
value: 186,
icon: markRaw(UserFilled),
bgColor: '#e8f4fd',
iconColor: '#409eff',
},
{
label: '在架课程数',
value: 324,
icon: markRaw(Reading),
bgColor: '#f0f9eb',
iconColor: '#67c23a',
},
{
label: '今日报名数',
value: 1520,
icon: markRaw(TrendCharts),
bgColor: '#fdf6ec',
iconColor: '#e6a23c',
},
{
label: '今日销售额(元)',
value: '¥ 48,930',
icon: markRaw(Money),
bgColor: '#fef0f0',
iconColor: '#f56c6c',
},
])
const enrollmentChartRef = ref(null)
const categoryChartRef = ref(null)
let enrollmentChart = null
let categoryChart = null
onMounted(() => {
initEnrollmentChart()
initCategoryChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
enrollmentChart?.dispose()
categoryChart?.dispose()
window.removeEventListener('resize', handleResize)
})
function handleResize() {
enrollmentChart?.resize()
categoryChart?.resize()
}
function initEnrollmentChart() {
enrollmentChart = echarts.init(enrollmentChartRef.value)
const option = {
tooltip: {
trigger: 'axis',
},
legend: {
data: ['新增学员', '新增报名', '活跃用户'],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['3/12', '3/13', '3/14', '3/15', '3/16', '3/17', '3/18'],
},
yAxis: {
type: 'value',
},
series: [
{
name: '新增学员',
type: 'line',
smooth: true,
areaStyle: { opacity: 0.15 },
data: [120, 158, 145, 210, 186, 234, 186],
},
{
name: '新增报名',
type: 'line',
smooth: true,
areaStyle: { opacity: 0.15 },
data: [320, 402, 358, 520, 470, 610, 1520],
},
{
name: '活跃用户',
type: 'line',
smooth: true,
areaStyle: { opacity: 0.15 },
data: [860, 920, 1100, 1050, 1240, 1380, 1420],
},
],
}
enrollmentChart.setOption(option)
}
function initCategoryChart() {
categoryChart = echarts.init(categoryChartRef.value)
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} 门 ({d}%)',
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
formatter: '{b}\n{d}%',
},
data: [
{ value: 98, name: '编程开发' },
{ value: 72, name: '设计创意' },
{ value: 56, name: '语言学习' },
{ value: 48, name: '职业考证' },
{ value: 50, name: '兴趣爱好' },
],
},
],
}
categoryChart.setOption(option)
}
</script>
<style lang="scss" scoped>
.stat-cards {
margin-bottom: 20px;
}
.stat-card {
:deep(.el-card__body) {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
}
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1d2130;
}
.stat-label {
font-size: 13px;
color: #999;
margin-top: 4px;
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.chart-container {
height: 360px;
}
</style>
4.2 ECharts 集成的工程化要点
上面的代码中有几个值得深入讨论的工程细节:
按需引入 ECharts。 我们没有 import * as echarts from 'echarts'(完整包约 1MB),而是从 echarts/core 引入核心模块,再手动注册需要的图表类型和组件。这样最终打包体积可以减少 60% 以上。
组件卸载时销毁实例。 onUnmounted 中调用 enrollmentChart.dispose() 释放 ECharts 占用的 Canvas 资源和内存中的事件监听器。如果跳过这一步,每次路由切换回看板页面都会创建新实例而不销毁旧实例,造成内存泄漏。
窗口 resize 监听与清理。 ECharts 实例不会自动响应容器尺寸变化,需要手动监听 resize 事件并调用实例的 .resize() 方法。同样,在卸载时要移除监听器。
markRaw 包装图标组件。 statCards 数组中的 icon 字段存放的是 Vue 组件对象。如果不用 markRaw 包装,Vue 会尝试将组件对象转为响应式代理,不仅浪费性能,还可能引发警告。markRaw 告诉 Vue:「这个对象不需要响应式追踪。」
五、CRUD 模块
5.1 设计理念:以课程管理为蓝本
CRUD(增删改查)是后台管理系统的核心战场——据统计,一个典型中后台项目 80% 的页面都是 CRUD 的变体。我们以「课程管理」为蓝本,完整实现搜索、表格、分页、新增/编辑弹窗、批量删除五个核心功能。掌握这一套模式后,「学员管理」「讲师管理」等页面只需要替换字段配置即可复制。
5.2 API 层:集中管理接口
// src/api/course.js
import request from '@/utils/request'
export function fetchCourseList(params) {
return request.get('/courses', { params })
}
export function fetchCourseDetail(id) {
return request.get(`/courses/${id}`)
}
export function createCourse(data) {
return request.post('/courses', data)
}
export function updateCourse(id, data) {
return request.put(`/courses/${id}`, data)
}
export function deleteCourses(ids) {
return request.delete('/courses', { data: { ids } })
}
将 API 函数集中到 api/ 目录有三大好处:第一,页面组件不直接依赖 Axios,如果未来换用 fetch 或其他 HTTP 库,只需修改 request.js 和 API 文件;第二,同一个接口如果被多个页面使用,不会出现 URL 拼写不一致的问题;第三,接口变更时,只需在一个地方修改。
5.3 可复用的分页逻辑
分页是每个列表页都需要的逻辑,非常适合抽取为组合式函数:
// src/composables/usePagination.js
import { reactive, toRefs } from 'vue'
export function usePagination(fetchFn, { defaultPageSize = 10, extraParams } = {}) {
// extraParams:可选,返回当前搜索条件的函数,确保翻页时不丢失搜索状态
const pagination = reactive({
currentPage: 1,
pageSize: defaultPageSize,
total: 0,
loading: false,
})
async function loadData(params = {}) {
pagination.loading = true
try {
// 合并分页参数、外部搜索条件、以及手动传入的参数
const extra = typeof extraParams === 'function' ? extraParams() : {}
const { list, total } = await fetchFn({
page: pagination.currentPage,
pageSize: pagination.pageSize,
...extra,
...params,
})
pagination.total = total
return list
} catch (error) {
console.error('数据加载失败:', error)
return []
} finally {
pagination.loading = false
}
}
function handlePageChange(page) {
pagination.currentPage = page
loadData() // 翻页时自动携带 extraParams 中的搜索条件
}
function handleSizeChange(size) {
pagination.pageSize = size
pagination.currentPage = 1
loadData()
}
function resetPage() {
pagination.currentPage = 1
}
return {
...toRefs(pagination),
loadData,
handlePageChange,
handleSizeChange,
resetPage,
}
}
这个 usePagination 封装了分页状态(当前页、每页条数、总数、loading)和页码变更方法。第二个参数可传入 extraParams 函数,返回当前搜索条件,确保翻页和切换每页条数时不会丢失搜索状态。任何列表页只需 const { currentPage, pageSize, total, loading, loadData, handlePageChange } = usePagination(fetchCourseList, { extraParams: () => ({ keyword: keyword.value }) }) 即可获得完整的分页能力。
5.4 课程列表页完整实现
<!-- src/views/course/CourseList.vue -->
<template>
<el-card shadow="never">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索课程名称"
clearable
style="width: 260px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="searchCategory"
placeholder="课程分类"
clearable
style="width: 160px"
@change="handleSearch"
>
<el-option label="编程开发" value="programming" />
<el-option label="设计创意" value="design" />
<el-option label="语言学习" value="language" />
<el-option label="职业考证" value="certification" />
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon> 查询
</el-button>
<div class="search-actions">
<el-button type="primary" @click="openDialog('add')">
<el-icon><Plus /></el-icon> 新增课程
</el-button>
<el-popconfirm
title="确定要删除选中的课程吗?"
@confirm="handleBatchDelete"
>
<template #reference>
<el-button type="danger" :disabled="!selectedRows.length">
<el-icon><Delete /></el-icon> 批量删除
</el-button>
</template>
</el-popconfirm>
</div>
</div>
<!-- 表格 -->
<el-table
v-loading="loading"
:data="courseList"
stripe
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column prop="courseName" label="课程名称" min-width="200" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="instructorName" label="讲师" width="120" />
<el-table-column prop="price" label="价格(元)" width="120" align="right">
<template #default="{ row }">
{{ row.price > 0 ? `¥ ${row.price.toFixed(2)}` : '免费' }}
</template>
</el-table-column>
<el-table-column prop="enrollCount" label="报名人数" width="100" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'published' ? 'success' : 'info'" size="small">
{{ row.status === 'published' ? '已上架' : '草稿' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDialog('edit', row.id)">
编辑
</el-button>
<el-popconfirm
title="确定要删除此课程吗?"
@confirm="handleDeleteOne(row.id)"
>
<template #reference>
<el-button link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchList"
@size-change="handleSizeChange"
/>
</div>
<!-- 新增/编辑弹窗 -->
<CourseDialog
ref="courseDialogRef"
@success="fetchList"
/>
</el-card>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Search, Plus, Delete } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchCourseList, deleteCourses } from '@/api/course'
import { usePagination } from '@/composables/usePagination'
import CourseDialog from './CourseDialog.vue'
const searchKeyword = ref('')
const searchCategory = ref('')
const courseList = ref([])
const selectedRows = ref([])
const courseDialogRef = ref(null)
const {
currentPage,
pageSize,
total,
loading,
loadData,
handlePageChange,
handleSizeChange,
resetPage,
} = usePagination(fetchCourseList, {
// 传入搜索条件工厂函数,确保翻页时自动携带当前搜索状态
extraParams: () => ({
keyword: searchKeyword.value,
category: searchCategory.value,
}),
})
onMounted(() => {
fetchList()
})
async function fetchList() {
const list = await loadData()
courseList.value = list
}
function handleSearch() {
resetPage()
fetchList()
}
function handleSelectionChange(rows) {
selectedRows.value = rows
}
function openDialog(mode, id) {
courseDialogRef.value.open(mode, id)
}
async function handleDeleteOne(id) {
await deleteCourses([id])
ElMessage.success('删除成功')
fetchList()
}
async function handleBatchDelete() {
if (!selectedRows.value.length) {
ElMessage.warning('请先选择要删除的课程')
return
}
const ids = selectedRows.value.map((row) => row.id)
await deleteCourses(ids)
ElMessage.success('批量删除成功')
fetchList()
}
</script>
<style lang="scss" scoped>
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-actions {
margin-left: auto;
display: flex;
gap: 12px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>
5.5 新增/编辑弹窗组件
弹窗组件通过 defineExpose 暴露 open 方法给父组件调用,内部根据 mode 判断是新增还是编辑:
<!-- src/views/course/CourseDialog.vue -->
<template>
<el-dialog
v-model="visible"
:title="mode === 'add' ? '新增课程' : '编辑课程'"
width="560px"
:close-on-click-modal="false"
@closed="resetForm"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="课程名称" prop="courseName">
<el-input v-model="formData.courseName" placeholder="请输入课程名称" />
</el-form-item>
<el-form-item label="课程分类" prop="category">
<el-select v-model="formData.category" placeholder="请选择分类" style="width: 100%">
<el-option label="编程开发" value="programming" />
<el-option label="设计创意" value="design" />
<el-option label="语言学习" value="language" />
<el-option label="职业考证" value="certification" />
</el-select>
</el-form-item>
<el-form-item label="授课讲师" prop="instructorId">
<el-select v-model="formData.instructorId" placeholder="请选择讲师" style="width: 100%">
<el-option
v-for="t in instructorOptions"
:key="t.id"
:label="t.name"
:value="t.id"
/>
</el-select>
</el-form-item>
<el-form-item label="课程价格" prop="price">
<el-input-number
v-model="formData.price"
:min="0"
:precision="2"
:step="10"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="课程简介" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="4"
placeholder="请输入课程简介"
/>
</el-form-item>
<el-form-item label="课程状态" prop="status">
<el-radio-group v-model="formData.status">
<!-- value prop 需 Element Plus 2.6.0+,旧版请使用 label -->
<el-radio value="draft">草稿</el-radio>
<el-radio value="published">上架</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
确 定
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchCourseDetail, createCourse, updateCourse } from '@/api/course'
const emit = defineEmits(['success'])
const visible = ref(false)
const mode = ref('add')
const editId = ref(null)
const submitting = ref(false)
const formRef = ref(null)
const instructorOptions = ref([
{ id: 1, name: '张教授' },
{ id: 2, name: '李老师' },
{ id: 3, name: '王讲师' },
])
const initialFormData = () => ({
courseName: '',
category: '',
instructorId: null,
price: 0,
description: '',
status: 'draft',
})
const formData = reactive(initialFormData())
const formRules = {
courseName: [
{ required: true, message: '请输入课程名称', trigger: 'blur' },
{ min: 2, max: 80, message: '课程名称 2-80 个字符', trigger: 'blur' },
],
category: [
{ required: true, message: '请选择课程分类', trigger: 'change' },
],
instructorId: [
{ required: true, message: '请选择授课讲师', trigger: 'change' },
],
price: [
{ required: true, message: '请输入课程价格', trigger: 'blur' },
],
}
async function open(dialogMode, id) {
mode.value = dialogMode
visible.value = true
if (dialogMode === 'edit' && id) {
editId.value = id
const detail = await fetchCourseDetail(id)
Object.assign(formData, detail)
}
}
function resetForm() {
Object.assign(formData, initialFormData())
formRef.value?.resetFields()
editId.value = null
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (mode.value === 'add') {
await createCourse({ ...formData })
ElMessage.success('课程创建成功')
} else {
await updateCourse(editId.value, { ...formData })
ElMessage.success('课程更新成功')
}
visible.value = false
emit('success')
} catch (err) {
// 错误已在拦截器中统一处理
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
这里有一个容易被忽略的细节:Object.assign(formData, initialFormData())。我们用一个工厂函数 initialFormData() 来生成初始值,而非直接赋值一个对象字面量。这样每次调用都会产生全新的对象,避免了「编辑课程 A 后打开新增弹窗,表单里还残留 A 的数据」的问题。
另一个关键设计是 defineExpose({ open })。在 <script setup> 模式下,组件内部的变量默认是私有的——父组件通过 ref 拿到子组件实例后,看不到任何属性和方法。必须通过 defineExpose 显式声明哪些成员可以被外部访问。这遵循「最小暴露原则」:只给外界它真正需要的东西。
5.6 从课程管理到其他模块的迁移
掌握了课程管理的完整模式后,学员管理和讲师管理只是「换表换字段」:
<!-- src/views/student/StudentList.vue(核心片段) -->
<el-table v-loading="loading" :data="studentList" stripe>
<el-table-column type="selection" width="50" />
<el-table-column prop="studentName" label="学员姓名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="enrolledCourses" label="已报课程数" align="center" />
<el-table-column prop="joinDate" label="注册日期" width="160" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
表格结构一致,API 函数替换为 fetchStudentList、deleteStudents,分页逻辑复用同一个 usePagination。当你发现三个页面有 70% 以上的相似度时,可以进一步抽象出配置驱动的通用 Table 组件——但切记不要过早抽象,先写三个具体实现,再提取共性,这比一上来就设计「万能组件」更实际。
六、项目收尾
6.1 全局错误处理
生产环境中,未捕获的异常不能只在控制台默默输出——用户看到白屏却不知道发生了什么,这是最差的体验。我们需要一张「安全网」:
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { vPermission } from './directives/permission'
const app = createApp(App)
// 全局错误处理
app.config.errorHandler = (err, instance, info) => {
console.error('[Global Error]', err)
console.error('[Component]', instance?.$options?.name || 'Anonymous')
console.error('[Info]', info)
// 生产环境可上报到错误监控平台(如 Sentry)
}
// 全局注册
app.use(createPinia())
app.use(router)
app.directive('permission', vPermission)
app.mount('#app')
app.config.errorHandler 可以捕获所有组件渲染函数、生命周期钩子、事件处理器中抛出的异常。配合 Sentry 等监控平台,你可以在用户遇到问题的第一时间收到告警,而非等用户投诉才知道。
6.2 Loading 状态管理策略
好的 Loading 策略遵循一条原则:让用户知道系统在工作,但不阻塞不相关的操作。
我们在 usePagination 中已经内置了 loading 状态,配合 el-table 的 v-loading 指令,表格区域会显示加载动画。但还有一些全局场景需要覆盖:
// src/utils/request.js(增强版,添加全局 Loading)
import { ElLoading } from 'element-plus'
let loadingInstance = null
let requestCount = 0
function showLoading() {
if (requestCount === 0) {
loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(255, 255, 255, 0.7)',
})
}
requestCount++
}
function hideLoading() {
requestCount--
if (requestCount <= 0) {
requestCount = 0
loadingInstance?.close()
}
}
这段代码使用引用计数模式:当有多个请求并发时,Loading 在第一个请求发起时显示,在最后一个请求结束后才关闭。避免了「第一个请求回来就关掉 Loading,但第二个请求还在飞」的问题。
需要注意的是,全局 Loading 不应该滥用——它会锁住整个页面。对于表格刷新这类局部操作,用组件级的 v-loading 就够了;全局 Loading 只适用于登录、页面初始化等确实需要阻塞交互的场景。可以通过在请求配置中传入自定义标志来区分:
// 需要全局 Loading 的请求
request.post('/auth/login', data, { showGlobalLoading: true })
// 普通请求不触发全局 Loading
request.get('/courses', { params })
6.3 国际化入门
当你的教育平台拓展到海外市场,国际化(i18n)就是必修课。Vue 3 生态中 vue-i18n 是事实标准:
npm install vue-i18n@9
// src/i18n/index.js
import { createI18n } from 'vue-i18n'
const messages = {
'zh-CN': {
menu: {
dashboard: '数据看板',
course: '课程管理',
student: '学员管理',
instructor: '讲师管理',
},
action: {
add: '新增',
edit: '编辑',
delete: '删除',
search: '查询',
confirm: '确定',
cancel: '取消',
},
},
'en-US': {
menu: {
dashboard: 'Dashboard',
course: 'Courses',
student: 'Students',
instructor: 'Instructors',
},
action: {
add: 'Add',
edit: 'Edit',
delete: 'Delete',
search: 'Search',
confirm: 'Confirm',
cancel: 'Cancel',
},
},
}
export const i18n = createI18n({
legacy: false, // 使用组合式 API 模式
locale: localStorage.getItem('locale') || 'zh-CN',
fallbackLocale: 'zh-CN',
messages,
})
在模板中使用翻译:
<el-button type="primary" @click="openDialog('add')">
<el-icon><Plus /></el-icon> {{ $t('action.add') }}
</el-button>
国际化的核心原则是将所有面向用户的文本从代码中抽离。即使当前只需要中文,提前建立 i18n 的架构也几乎没有额外成本,但后续新增语言时会省去大量的全局搜索替换工作。
6.4 构建与部署
项目开发完成后,运行构建命令:
npm run build
Vite 会将代码打包到 dist/ 目录(Vite 8 起生产构建使用 Rolldown,配置项与 Rollup 兼容)。你可以通过以下配置优化构建产物:
// vite.config.js(补充构建配置)
export default defineConfig({
// ...前面的配置
build: {
// 将体积较大的第三方库拆分为独立 chunk
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{ name: 'element-plus', test: /node_modules[\\/]element-plus/, priority: 20 },
{ name: 'echarts', test: /node_modules[\\/]echarts/, priority: 20 },
],
},
},
},
// 开启 gzip 压缩提示
reportCompressedSize: true,
},
})
codeSplitting 将 Element Plus 和 ECharts 拆分成独立的 JS 文件(Vite 8 中 rolldownOptions 替代了原来的 rollupOptions,codeSplitting 替代了已废弃的 manualChunks)。这样当你更新业务代码时,用户浏览器缓存的 UI 库和图表库代码不需要重新下载,节约带宽。
部署到 Nginx 时,需要注意一个关键配置——因为我们使用了 createWebHistory(History 模式路由),所有路径都需要 fallback 到 index.html:
server {
listen 80;
server_name edu-admin.example.com;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理
location /edu-api/ {
proxy_pass http://backend-server:3000/;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
try_files $uri $uri/ /index.html 的含义是:当 Nginx 找不到请求路径对应的静态文件时,回退到 index.html,让 Vue Router 在前端处理路由。没有这行配置,用户直接访问 /course 或刷新页面时会看到 404。
🤔 思考题
-
状态管理边界:在本项目中,课程列表数据存放在组件内部的
ref中,而用户信息存放在 Pinia Store 中。你能说出这两种策略的选择依据吗?如果课程列表需要在多个页面共享(比如侧边栏也要显示最近编辑的课程),你会如何调整? -
组件抽象时机:本章的课程管理、学员管理、讲师管理三个页面结构高度相似。如果你要设计一个配置驱动的通用
CrudTable组件,你会让它接收哪些 Props?你认为这样做的收益和代价分别是什么? -
安全加固:当前的权限控制是前端级别的(路由守卫 + 按钮权限指令)。一个懂技术的恶意用户是否可以绕过这些限制?如果要构建一个真正安全的权限系统,前后端各应该承担什么职责?
-
性能优化:数据看板页面同时渲染了两个 ECharts 图表和四个数据卡片。如果图表数据需要调用 API 获取(而非硬编码),你会如何组织请求——串行等所有数据到齐后一起渲染,还是并行请求各自独立渲染?各有什么优缺点?
📝 结尾自测
-
为什么登录页使用
router.replace('/dashboard')而非router.push('/dashboard')?如果使用push,会产生什么用户体验问题? -
在 Axios 响应拦截器中,我们对
401状态码做了全局拦截并跳转登录页。如果用户同时触发了三个并行请求且 Token 全都过期,会发生什么?你会如何优化? -
usePagination组合式函数返回的loading是一个ref。如果父组件在模板中写v-loading="loading",不加.value也能正常工作,这是为什么? -
在
CourseDialog.vue中,为什么用Object.assign(formData, initialFormData())重置表单,而不是formData = initialFormData()?如果用后者会发生什么? -
路由配置中,所有业务页面都嵌套在
AdminLayout的children里,而登录页是顶层路由。这种结构如何实现了「登录页不显示侧边栏」的效果?相比在App.vue中用v-if判断路径,这种方案有什么优势?
实战项目让你把前八章的知识融会贯通。但功能完成只是起点,下一章我们将进入性能优化领域,学习如何让你的 Vue 应用跑得更快、加载得更少、响应得更及时。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90