跳到主要内容
预计阅读 113 分钟

实战:管理后台项目

建筑师不会在画完蓝图后才第一次拿起锤子。真正的理解来自建造——把钢筋、混凝土和管线在三维空间里协调到位。前面八章,你已经掌握了 Vue 3 从模板语法到工程化实践的全部核心知识。现在,我们要把这些散落的零件组装成一台完整的机器:一个在线教育管理平台的后台系统。


📋 开篇自测

在动手之前,先检验你的知识储备:

  1. 如果一个后台系统有 20 个页面,但它们共享同一套侧边栏和顶部工具栏,你会把布局代码放在哪里?为什么不在每个页面里都写一份?
  2. 用户登录后获取的 Token 应该存储在哪里?当 Token 过期时,前端应该如何优雅地引导用户重新登录?
  3. 当你有「课程管理」「讲师管理」「学员管理」三个页面,表格结构几乎一样,你会为每个页面写一套独立的 Table 代码,还是会抽象出通用组件?你的判断依据是什么?

如果这三个问题你能清晰作答,可以选读感兴趣的小节;否则,请跟随本章逐一攻破。


一、项目规划与架构设计

1.1 需求分析:在线教育管理平台

假设你接到了一个真实需求:为「星辰在线教育」搭建管理后台。产品经理给出的核心功能清单如下:

  • 数据看板:展示今日新增学员数、课程销售额、活跃用户数等核心指标,并配有趋势图表
  • 课程管理:课程列表的增删改查,支持搜索、分页、批量操作
  • 学员管理:学员信息浏览与筛选
  • 讲师管理:讲师信息的维护
  • 登录鉴权:管理员登录、Token 鉴权、路由权限控制

拿到需求后,很多初学者会立刻打开编辑器开始写代码。但经验丰富的工程师会先做一件事——架构设计。这就像盖楼之前先打地基、画结构图:地基歪了,楼层再高也撑不住。

1.2 技术选型

结合第八章的工程化知识,我们做出以下技术决策:

层面选型理由
构建工具Vite毫秒级冷启动,原生 ESM 按需编译
UI 框架Element PlusVue 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-componentsunplugin-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: 1overflow-y: auto,确保内容超出时只有中间部分滚动,顶部和底部始终可见

2.2 侧边栏导航

侧边栏是整个后台的「交通枢纽」。我们通过 el-menurouter 属性将菜单项与路由绑定,点击菜单时自动触发路由跳转:

<!-- 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.pushdefault-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 函数替换为 fetchStudentListdeleteStudents,分页逻辑复用同一个 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-tablev-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 替代了原来的 rollupOptionscodeSplitting 替代了已废弃的 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。


🤔 思考题

  1. 状态管理边界:在本项目中,课程列表数据存放在组件内部的 ref 中,而用户信息存放在 Pinia Store 中。你能说出这两种策略的选择依据吗?如果课程列表需要在多个页面共享(比如侧边栏也要显示最近编辑的课程),你会如何调整?

  2. 组件抽象时机:本章的课程管理、学员管理、讲师管理三个页面结构高度相似。如果你要设计一个配置驱动的通用 CrudTable 组件,你会让它接收哪些 Props?你认为这样做的收益和代价分别是什么?

  3. 安全加固:当前的权限控制是前端级别的(路由守卫 + 按钮权限指令)。一个懂技术的恶意用户是否可以绕过这些限制?如果要构建一个真正安全的权限系统,前后端各应该承担什么职责?

  4. 性能优化:数据看板页面同时渲染了两个 ECharts 图表和四个数据卡片。如果图表数据需要调用 API 获取(而非硬编码),你会如何组织请求——串行等所有数据到齐后一起渲染,还是并行请求各自独立渲染?各有什么优缺点?


📝 结尾自测

  1. 为什么登录页使用 router.replace('/dashboard') 而非 router.push('/dashboard')?如果使用 push,会产生什么用户体验问题?

  2. 在 Axios 响应拦截器中,我们对 401 状态码做了全局拦截并跳转登录页。如果用户同时触发了三个并行请求且 Token 全都过期,会发生什么?你会如何优化?

  3. usePagination 组合式函数返回的 loading 是一个 ref。如果父组件在模板中写 v-loading="loading",不加 .value 也能正常工作,这是为什么?

  4. CourseDialog.vue 中,为什么用 Object.assign(formData, initialFormData()) 重置表单,而不是 formData = initialFormData()?如果用后者会发生什么?

  5. 路由配置中,所有业务页面都嵌套在 AdminLayoutchildren 里,而登录页是顶层路由。这种结构如何实现了「登录页不显示侧边栏」的效果?相比在 App.vue 中用 v-if 判断路径,这种方案有什么优势?


实战项目让你把前八章的知识融会贯通。但功能完成只是起点,下一章我们将进入性能优化领域,学习如何让你的 Vue 应用跑得更快、加载得更少、响应得更及时。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90