Ruo-Yi基础篇(六):第六章. 前端业务模块开发实战:课程管理

第六章. 前端业务模块开发实战:课程管理

6.1. 任务启动:环境初始化与需求分析

6.1.1. 核心目标

本章的核心任务是,在若依(RuoYi-Vue3)前端项目中,手动从零开始开发一个功能完备的“课程管理”模块。此过程旨在模拟一个真实的企业级开发场景:后端接口已开发完毕,前端工程师需依据接口契约,独立完成视图层的全部构建工作。

完成本章的学习后,我们将不再仅仅满足于使用代码生成器,而是具备深度定制、优化乃至重构复杂业务模块的能力,将对若依前端的理解从“应用层”深化至“原理层”。


6.1.2. 准备工作:数据库初始化

开发工作始于数据环境的统一。在开始编写任何前端代码之前,我们必须确保本地数据库中拥有与后端开发环境一致的表结构和基础数据。

请在您的数据库客户端(如 Navicat, DataGrip 等)中执行下面的 SQL 脚本。此脚本将完成两项关键操作:

  1. 创建名为 tb_course 的课程管理表。
  2. tb_course 表中插入 20 条结构化的测试数据,以供后续开发与调试使用。

操作前置条件: 为确保数据环境的纯净与一致性,如果您本地数据库中已存在名为 tb_coursecourse 的数据表,请务-必在执行此脚本前将其删除,以避免任何潜在的字段冲突或数据不一致问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- 删除已存在的旧表 (如果存在)
DROP TABLE IF EXISTS `tb_course`;

-- 创建课程管理表
CREATE TABLE `tb_course` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '课程id',
`code` varchar(50) NOT NULL COMMENT '课程编码',
`subject` char(1) NOT NULL COMMENT '课程学科(0:JavaEE 1:Python 2:鸿蒙)',
`name` varchar(100) NOT NULL COMMENT '课程名称',
`price` decimal(10,2) NOT NULL COMMENT '价格',
`applicable_person` varchar(200) NOT NULL COMMENT '适用人群',
`info` varchar(500) DEFAULT NULL COMMENT '课程介绍',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='课程管理表';

-- 插入 20 条课程测试数据
INSERT INTO `tb_course` (`id`, `code`, `subject`, `name`, `price`, `applicable_person`, `info`, `create_time`, `update_time`) VALUES
(1, 'CS101', '0', 'JavaEE入门', 1000.00, '大学生', 'JavaEE基础课程,适合初学者', '2023-04-01 08:00:00', '2023-04-01 08:00:00'),
(2, 'CS102', '1', 'Python基础', 800.00, '编程爱好者', 'Python基础课程,适合编程初学者', '2023-04-02 08:00:00', '2023-04-02 08:00:00'),
(3, 'CS103', '2', '鸿蒙操作系统概述', 1200.00, '开发人员', '鸿蒙操作系统介绍,适合开发人员', '2023-04-03 08:00:00', '2023-04-03 08:00:00'),
(4, 'CS104', '0', 'JavaEE高级应用', 1500.00, '有一定Java基础的学员', 'JavaEE高级课程,适合有一定Java基础的学员', '2023-04-04 08:00:00', '2023-04-04 08:00:00'),
(5, 'CS105', '1', 'Python数据分析', 1100.00, '数据分析师', 'Python数据分析课程,适合数据分析师', '2023-04-05 08:00:00', '2023-04-05 08:00:00'),
(6, 'CS106', '2', '鸿蒙开发实战', 1300.00, '鸿蒙开发者', '鸿蒙开发实战课程,适合鸿蒙开发者', '2023-04-06 08:00:00', '2023-04-06 08:00:00'),
(7, 'CS107', '0', 'JavaEE框架应用', 1400.00, 'Java开发人员', 'JavaEE框架应用课程,适合Java开发人员', '2023-04-07 08:00:00', '2023-04-07 08:00:00'),
(8, 'CS108', '1', 'Python网络编程', 900.00, '网络工程师', 'Python网络编程课程,适合网络工程师', '2023-04-08 08:00:00', '2023-04-08 08:00:00'),
(9, 'CS109', '2', '鸿蒙系统架构', 1600.00, '系统架构师', '鸿蒙系统架构课程,适合系统架构师', '2023-04-09 08:00:00', '2023-04-09 08:00:00'),
(10, 'CS110', '0', 'JavaEE企业级应用', 1700.00, '企业开发人员', 'JavaEE企业级应用课程,适合企业开发人员', '2023-04-10 08:00:00', '2023-04-10 08:00:00'),
(11, 'CS111', '1', 'Python机器学习', 1300.00, '机器学习爱好者', 'Python机器学习课程,适合机器学习爱好者', '2023-04-11 08:00:00', '2023-04-11 08:00:00'),
(12, 'CS112', '2', '鸿蒙开发进阶', 1800.00, '有经验的鸿蒙开发者', '鸿蒙开发进阶课程,适合有经验的鸿蒙开发者', '2023-04-12 08:00:00', '2023-04-12 08:00:00'),
(13, 'CS113', '0', 'JavaEE性能优化', 1200.00, 'Java性能优化工程师', 'JavaEE性能优化课程,适合Java性能优化工程师', '2023-04-13 08:00:00', '2023-04-13 08:00:00'),
(14, 'CS114', '1', 'Python Web开发', 1100.00, 'Web开发者', 'Python Web开发课程,适合Web开发者', '2023-04-14 08:00:00', '2023-04-14 08:00:00'),
(15, 'CS115', '2', '鸿蒙系统安全', 1500.00, '系统安全专家', '鸿蒙系统安全课程,适合系统安全专家', '2023-04-15 08:00:00', '2023-04-15 08:00:00'),
(16, 'CS116', '0', 'JavaEE云计算', 1400.00, '云计算工程师', 'JavaEE云计算课程,适合云计算工程师', '2023-04-16 08:00:00', '2023-04-16 08:00:00'),
(17, 'CS117', '1', 'Python人工智能', 1600.00, '人工智能工程师', 'Python人工智能课程,适合人工智能工程师', '2023-04-17 08:00:00', '2023-04-17 08:00:00'),
(18, 'CS118', '2', '鸿蒙系统设计', 1700.00, '系统设计师', '鸿蒙系统设计课程,适合系统设计师', '2023-04-18 08:00:00', '2023-04-18 08:00:00'),
(19, 'CS119', '0', 'JavaEE微服务', 1800.00, '微服务架构师', 'JavaEE微服务课程,适合微服务架构师', '2023-04-19 08:00:00', '2023-04-19 08:00:00'),
(20, 'CS120', '1', 'Python爬虫实战', 1300.00, '爬虫工程师', 'Python爬虫实战课程,适合爬虫工程师', '2023-04-20 08:00:00', '2023-04-20 08:00:00');

6.2. 接口契约:定义 API 服务模块

6.2.1. 场景设定:接收后端 API 接口文档

在完成数据库初始化后,我们进入了真实开发流程中的关键一步:前后端协作的起点。我们假设已从后端开发团队处获得一份详尽的“课程管理模块 API 接口文档”。这份文档是前后端的“契约”,它精确定义了数据请求的路径、方法、参数和响应结构。后续所有的前端开发工作,都将严格围绕这份契约展开。

API 关键信息概览:

  • 模块根路径: /course/Course
  • 数据字典依赖:
    • 字典类型: course_subject
    • 字典数据: 在后续开发中,我们需要从系统中获取此字典,其数据结构为 0=JavaEE, 1=Python, 2=鸿蒙

6.2.2. API 接口清单

下表详细列出了我们将要对接的全部后端接口。

功能描述HTTP 方法URL (基于根路径)请求参数/体关键响应格式
查询课程列表(分页)GET/listquery (URL Params){ code, msg, rows, total }
获取课程详情GET/{id}id (Path Variable){ code, msg, data }
新增课程POST/data (Request Body){ code, msg }
修改课程PUT/data (Request Body){ code, msg }
删除课程DELETE/{id}id (Path Variable){ code, msg }

6.2.3. 构建 API 服务层

在开始构建任何 UI 界面之前,最佳的工程实践是先创建一个专门的 API 服务层。我们将所有与“课程管理”相关的 HTTP 请求封装成一个个独立的、语义清晰的函数。
设计原则:服务层解耦
建立独立的 API 服务层是前端工程化的核心实践。它至少带来两大好处:

  1. 解耦: 将数据获取的复杂逻辑(如 URL 构造、请求方法、参数处理)与视图组件(.vue 文件)的业务逻辑彻底分离。组件只关心“调用一个函数”,而不用关心这个函数背后是如何发送 HTTP 请求的。
  2. 复用: 同一个 API 函数(例如 getCourse(id))可能在项目的多个不同页面或组件中被需要。将其封装后,任何地方都可以通过 import 安全地复用,避免了代码的重复编写。

现在,我们在指定的目录下创建 course.js 文件。

文件路径: src/api/course/course.js

首先,我们需要导入若依框架封装好的 axios 实例,它位于 @/utils/request

1
2
3
// **文件路径**: src/api/course/course.js

import request from '@/utils/request'

@/utils/request.js 并不仅仅是一个简单的 axios 实例。它内置了请求拦截器(用于自动附加认证 Token)和响应拦截器(用于统一处理 HTTP 错误码和数据结构),极大地简化了我们的业务代码。

接下来,我们根据 API 接口清单,逐一实现对应的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// **文件路径**: src/api/course/course.js

import request from '@/utils/request'

// 1. 查询课程管理列表
// query 参数将作为 URL 的查询字符串 (e.g., /list?pageNum = 1&pageSize = 10)
export function listCourse(query) {
return request({
url: '/course/Course/list',
method: 'get',
params: query
})
}

// 2. 查询课程管理详细
// id 参数将作为路径的一部分 (e.g., /course/Course/1)
export function getCourse(id) {
return request({
url: '/course/Course/' + id,
method: 'get'
})
}

// 3. 新增课程管理
// data 参数将作为请求体 (Request Body) 发送
export function addCourse(data) {
return request({
url: '/course/Course',
method: 'post',
data: data
})
}

// 4. 修改课程管理
// data 参数将作为请求体 (Request Body) 发送
export function updateCourse(data) {
return request({
url: '/course/Course',
method: 'put',
data: data
})
}

// 5. 删除课程管理
// id 参数将作为路径的一部分 (e.g., /course/Course/1)
export function delCourse(id) {
return request({
url: '/course/Course/' + id,
method: 'delete'
})
}

至此,我们的 API 服务层已经构建完毕。我们拥有了一套与后端契约完全匹配、可随时调用的前端函数库。这是我们接下来构建视图层(index.vue)的坚实基础。


6.3. 视图层构建:index.vue 的分步实现

在完成了 API 服务层的封装后,我们现在将工作的重心转移到用户直接交互的界面层。本节,我们将采用“渐进式”的开发策略,从零开始,一步步地为项目添加 src/views/course/course/index.vue 文件,并为其注入生命力。这种开发方式能让我们清晰地看到一个复杂组件是如何从最基础的结构“生长”为功能完备的形态的。


6.3.1. 步骤一:初始化组件结构与响应式状态

任务:
本步骤的核心任务是奠定整个组件的根基。我们将完成两项工作:

  1. 创建文件并搭建静态布局: 在 <template> 中构建出页面的宏观结构,包含所有功能区域的静态占位。
  2. 定义全部响应式状态: 在 <script setup> 中,预先定义好驱动整个组件行为所需的所有响应式变量。

首先,请在 src/views/course/ 目录下创建一个新的 course 文件夹,并在其中新建 index.vue 文件。

文件路径: src/views/course/course/index.vue

1. 搭建 <template> 静态骨架

我们先不关心任何动态逻辑,只专注于用 HTML 和 Element Plus 组件搭建出页面的视觉框架。这个框架将包含五个主要功能区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- **文件路径**: src/views/course/course/index.vue -->
<template>
<div class="app-container">
<!-- 1. 搜索表单区域 -->
<el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
<!-- 表单项将在此处添加 -->
</el-form>

<!-- 2. 操作按钮工具栏 -->
<el-row :gutter="10" class="mb8">
<!-- 各类操作按钮将在此处添加 -->
</el-row>

<!-- 3. 数据展示表格 -->
<el-table v-loading="loading" :data="courseList">
<!-- 表格列定义将在此处添加 -->
</el-table>

<!-- 4. 分页组件 -->
<pagination
v-show="total>0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
/>

<!-- 5. 添加或修改课程对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<!-- 对话框内的表单将在此处添加 -->
</el-dialog>
</div>
</template>

2. 编写 <script setup> 并定义响应式状态

现在,我们为刚刚搭建的骨架定义“灵魂”——即驱动其所有动态行为的数据状态。

首先,我们引入所有必需的模块,包括 Vue 的核心 API 和我们在上一步中封装好的 API 服务函数。

1
2
3
4
5
6
// **文件路径**: src/views/course/course/index.vue

<script setup name="Course">
import { ref, reactive, toRefs, getCurrentInstance } from 'vue';
import { listCourse, getCourse, delCourse, addCourse, updateCourse } from "@/api/course/course";
</script>

name="Course" 是一个重要的工程实践。它为 <script setup> 组件定义了一个明确的名称,这在 Vue Devtools 中进行调试,以及在使用 <keep-alive> 进行组件缓存时都至关重要。

接下来,我们定义组件所需的所有响应式变量。提前定义好全部变量,有助于我们从宏观上理解组件的状态复杂度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// ... imports ...

// 获取当前组件实例,用于访问全局挂载的属性和方法
const { proxy } = getCurrentInstance();

// 从系统中获取预定义的字典数据
const { course_subject } = proxy.useDict('course_subject');

// --- 视图控制状态 ---
const loading = ref(true); // 表格加载遮罩状态
const showSearch = ref(true); // 是否显示搜索表单
const open = ref(false); // 是否显示新增/修改对话框
const title = ref(""); // 对话框标题

// --- 数据状态 ---
const courseList = ref([]); // 表格核心数据
const total = ref(0); // 数据总条数,用于分页

// --- 表格选择与按钮联动状态 ---
const ids = ref([]); // 存储表格选中行的 ID
const single = ref(true); // 控制“修改”按钮的禁用状态(非单个选中时为 true)
const multiple = ref(true); // 控制“删除”按钮的禁用状态(非多个选中时为 true)

// --- 表单与查询参数 ---
// 使用 reactive 将关联性强的数据组织在一起
const data = reactive({
form: {}, // 新增/修改时使用的表单对象
queryParams: {
// 分页和查询参数
pageNum: 1,
pageSize: 10,
code: null,
subject: null,
name: null,
applicablePerson: null,
},
rules: {
// 表单校验规则
code: [{ required: true, message: "课程编码不能为空", trigger: "blur" }],
subject: [{ required: true, message: "课程学科不能为空", trigger: "change" }],
name: [{ required: true, message: "课程名称不能为空", trigger: "blur" }],
price: [{ required: true, message: "价格不能为空", trigger: "blur" }],
applicablePerson: [{ required: true, message: "适用人群不能为空", trigger: "blur" }],
info: [{ required: true, message: "课程介绍不能为空", trigger: "blur" }],
}
});

// 使用 toRefs 将 reactive 对象解构为 ref,以便在模板中直接使用
const { queryParams, form, rules } = toRefs(data);
技术问答
2025-11-03 17:00
学习者

为什么 loadingtotal 这些变量使用 ref,而 formqueryParams 却要包裹在 reactive 中,还要用 toRefs 解构?

专家

这是 Vue 3 Composition API 的一个核心实践模式。对于独立的、单一的值(如布尔值、数字、字符串),或者整个数组的替换,使用 ref 更为直接。而对于一组逻辑上紧密关联的数据,比如一个表单的所有字段 (form) 或一个查询的所有参数 (queryParams),将它们组织在一个 reactive 对象中,可以使数据结构更清晰,管理更方便。

专家

toRefs 的作用则是为了在模板中能方便地直接使用 queryParams 里的属性(如 v-model="queryParams.code"),同时保持其与原始 reactive 对象的响应式链接。如果不使用 toRefs 直接解构,会丢失响应性。

至此,我们已经完成了 index.vue 的初始化工作。我们拥有了一个结构清晰的静态模板和一套完备的、准备驱动视图变化的响应式数据。下一步,我们将开始编写业务逻辑,让页面真正“动”起来。

6.3.2. 步骤二:实现核心功能 - 列表查询与渲染

任务:
在上一步中,我们已经搭建了页面的骨架并定义了数据状态。现在,我们的核心任务是打通前后端的数据链路:调用 API 从后端获取课程列表数据,并将其动态渲染到 <el-table> 组件中。

1. 完善 <template> 表格区域

首先,我们为 <el-table> 组件添加所有需要展示的列(<el-table-column>)。

  • 绑定数据源: 将 :data 属性绑定到我们在上一步中定义的 courseList 响应式变量。
  • 定义列: 为 tb_course 表的每个关键字段创建一个对应的列,并通过 prop 属性指定其对应的数据字段名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!-- **文件路径**: src/views/course/course/index.vue -->

<!-- ...el-form... -->
<!-- ...el-row... -->

<!-- 3. 数据展示表格 -->
<el-table v-loading="loading" :data="courseList" @selection-change="handleSelectionChange">
<!-- 复选框列,为后续批量操作做准备 -->
<el-table-column type="selection" width="55" align="center" />

<!-- 数据列 -->
<el-table-column label="课程id" align="center" prop="id" />
<el-table-column label="课程编码" align="center" prop="code" />

<!-- 特殊处理:课程学科列 -->
<el-table-column label="课程学科" align="center" prop="subject">
<template #default="scope">
<dict-tag :options="course_subject" :value="scope.row.subject"/>
</template>
</el-table-column>

<el-table-column label="课程名称" align="center" prop="name" />
<el-table-column label="价格" align="center" prop="price" />
<el-table-column label="适用人群" align="center" prop="applicablePerson" />
<el-table-column label="课程介绍" align="center" prop="info" />

<!-- 操作列,为后续的修改和删除功能预留 -->
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<!-- 操作按钮将在此处添加 -->
</template>
</el-table-column>
</el-table>

<!-- ...pagination... -->
<!-- ...el-dialog... -->

2. 实现 getList 数据获取函数

接下来,我们在 <script setup> 区域编写获取列表数据的核心逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// **文件路径**: src/views/course/course/index.vue (`<script setup>` 部分)


/**
* 查询课程管理列表
* 这是本组件最核心的函数之一,负责与后端进行数据交互
*/
function getList() {
// 1. 开启表格加载状态,显示 loading 遮罩
loading.value = true;

// 2. 调用我们在 6.2.3 节封装的 API 服务函数
listCourse(queryParams.value).then(response => {
// 3. API 调用成功后,将后端返回的数据赋值给对应的响应式变量
courseList.value = response.rows; // 更新表格数据
total.value = response.total; // 更新总条数,用于分页组件

// 4. 关闭表格加载状态
loading.value = false;
});
}

// 在组件 setup 阶段的末尾,立即调用一次 getList 函数
// 这将确保用户进入页面时,能立刻看到初始数据
getList();

现在,当组件被加载时,getList 函数会自动执行,从后端获取第一页的数据并渲染到表格中。

image-20251103170608028

3. 深度解析:若依的 <dict-tag> 组件与 useDict

在表格的“课程学科”列,我们没有直接显示 scope.row.subject 的值(它可能是 ‘0’, ‘1’, ‘2’),而是使用了一个特殊的组件 <dict-tag>

技术深度追问
2025-11-03 17:15

<dict-tag/> 这行代码是如何工作的?course_subject 这个数据又是从哪里来的?

专家

问得非常好,这触及了若依前端框架一个非常便捷的设计。course_subject 来自于我们在脚本顶部通过 const { course_subject } = proxy.useDict('course_subject'); 获取的数据。

专家

useDict 是若依封装的一个组合式函数。当你传入字典类型名(如 'course_subject'),它会执行以下操作:

专家

首先检查全局的 Pinia/Vuex Store 中是否已经缓存了 'course_subject' 的字典数据。

专家

如果有缓存,直接从缓存返回。

专家

如果没有缓存,它会向后端发起一个异步请求(通常是 /system/dict/data/type/course_subject),获取该字典的完整数据(一个包含 label, value, elTagType 等属性的数组)。

专家

获取成功后,它会将这份数据存入 Store 进行缓存,然后再返回给你。

专家

这样,course_subject 就得到了一个类似 [{label: "JavaEE", value: "0", elTagType: "primary"}, ...] 的数组。

<dict-tag> 组件呢?

专家

<dict-tag> 是一个展示型组件。它接收 options(我们传入的 course_subject 数组)和 value(当前行的学科值,如 ‘0’)。组件内部会根据 valueoptions 数组中查找匹配的对象,然后使用该对象的 label 作为显示文本,elTagType 作为 Element Plus 标签的 type,最终渲染出一个带颜色和正确文本的 <el-tag>

明白了,所以 useDict 负责“取数据”,<dict-tag> 负责“展示数据”,两者结合,就高效地解决了后端返回的原始码值与前端需要展示的人性化文本之间的转换问题。

通过以上步骤,我们不仅成功地将后端数据显示在了页面上,还深入理解了若依框架中处理数据字典的核心机制。我们的页面已经从一个静态的骨架,变成了一个能够展示真实数据的动态表格。


6.3.3. 步骤三:构建交互功能 - 搜索、重置与分页

任务:
目前,我们的页面已经能够展示数据,但它还是一个“只读”的静态列表。本步骤的任务是为页面注入交互能力,我们将实现两个核心功能:

  1. 分页浏览: 激活底部的分页组件,让用户可以浏览所有数据。
  2. 条件搜索: 激活顶部的搜索表单,允许用户根据特定条件筛选数据。

1. 完善 <template> 搜索表单区域

首先,我们在 6.3.1 步骤预留的 <el-form> 区域内,添加具体的表单项。

  • v-model 绑定: 将每个输入控件(如 el-input, el-select)的 v-model 指令,与 queryParams 对象中对应的属性进行双向绑定。
  • 事件监听: 为“搜索”和“重置”按钮绑定 @click 事件,并为输入框添加 @keyup.enter 事件以提升用户体验。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<!-- **文件路径**: src/views/course/course/index.vue -->
<template>
<div class="app-container">
<!-- 1. 搜索表单区域 -->
<el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="课程编码" prop="code">
<el-input
v-model="queryParams.code"
placeholder="请输入课程编码"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="课程学科" prop="subject">
<el-select v-model="queryParams.subject" placeholder="请选择课程学科" clearable>
<el-option
v-for="dict in course_subject"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="课程名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入课程名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="适用人群" prop="applicablePerson">
<el-input
v-model="queryParams.applicablePerson"
placeholder="请输入适用人群"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>

<!-- ...el-row, el-table... -->

<!-- 4. 分页组件 -->
<pagination
v-show="total>0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>

<!-- ...el-dialog... -->
</div>
</template>

2. 实现搜索、重置与分页的逻辑函数

现在,我们在 <script setup> 区域内,编写与上述模板交互所需的逻辑函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// **文件路径**: src/views/course/course/index.vue (`<script setup>` 部分)

// ... imports and reactive state definitions ...

// ... getList function ...

/** 搜索按钮操作 */
function handleQuery() {
// 关键操作:将当前页码重置为 1
queryParams.value.pageNum = 1;
// 重新调用 getList 函数,此时会携带最新的查询参数
getList();
}

/** 重置按钮操作 */
function resetQuery() {
// 调用若依全局封装的表单重置方法
proxy.resetForm("queryRef");
// 重置后,立即执行一次查询,恢复到初始列表状态
handleQuery();
}

// ... getList() initial call ...

3. 深度解析:交互功能背后的设计思想

我们的页面现在已经完全具备了交互性,让我们深入分析其工作流程和设计模式。

分页功能的工作流:

  1. 数据双向绑定: 我们使用 v-model:pagev-model:limit<pagination> 组件的内部状态与父组件的 queryParams.pageNumqueryParams.pageSize 进行了双向绑定。这意味着当用户在分页组件上进行操作(如点击“下一页”),queryParams 对象的值会 自动更新
  2. 事件驱动更新: 当分页组件的状态发生改变时,它会向外触发一个名为 @pagination 的事件。
  3. 直接调用: 我们将 @pagination 事件直接绑定到了 getList 函数。
  4. 闭环流程: 这一系列操作形成了一个完美的数据驱动闭环:用户操作 → 更新 queryParams → 触发事件 → 调用 getList → API 携带新参数请求 → 更新表格数据。父组件(index.vue)无需关心分页组件的内部实现,只需提供数据和响应事件即可,这是组件化开发的典范。

搜索功能的逻辑核心:

技术问答
2025-11-03 17:30

handleQuery 函数中,queryParams.value.pageNum = 1; 这一行代码的目的是什么?可以去掉吗?

专家

这是条件搜索功能中一个至关重要的细节,绝对不能去掉。设想一个场景:用户当前正在浏览列表的第 5 页,此时他输入了一个新的搜索条件并点击了“搜索”。

专家

如果我们不去重置页码,getList 函数会带着 pageNum: 5 和新的搜索条件去请求数据。但新的搜索结果可能总共只有 2 页,那么请求第 5 页自然会返回空数据,用户就会看到一个空白的表格,这是一种糟糕的用户体验。

专家

因此,任何一次新的条件搜索,都必须将页码重置回第一页,以确保用户能从头开始浏览新的结果集。

proxy.resetForm("queryRef") 这个函数似乎很方便,它是如何工作的?

专家

proxy.resetForm 是若依挂载到全局的一个工具函数。它的工作原理如下:

专家

它接收一个字符串参数,即我们在 <el-form> 上通过 ref="queryRef" 定义的引用名称。

专家

在函数内部,它通过 proxy.$refs['queryRef'] 获取到 Element Plus 的表单组件实例。

专家

获取到实例后,它会调用该实例自身提供的 resetFields() 方法。这个方法是 Element Plus Form 组件的内置功能,能够将所有表单项的值重置为初始值,并移除校验结果。

专家

所以,若依的 resetForm 本质上是对 Element Plus 原生功能的一个便捷封装,让我们无需在每个组件中都手动编写获取 ref 并调用方法的代码。

通过本步骤的构建,我们的页面不再仅仅是数据的展示板,而成为了一个功能性的、可交互的应用程序界面。用户现在可以自如地在海量数据中穿梭和筛选。


6.3.4. 步骤四:功能实现:数据行选择与状态同步

任务:
在实现具体的“修改”和“删除”功能之前,我们必须先完成一项基础但至关重要的交互设计:根据用户在表格中的行选择行为,动态地控制操作按钮(如“修改”、“删除”)的可用状态

具体的用户体验目标如下:

  • 默认状态: 当表格没有任何行被选中时,“修改”和“批量删除”按钮应处于禁用状态。
  • 单选状态: 当用户只选中一行数据时,“修改”和“批量删除”按钮都应变为可用状态。
  • 多选状态: 当用户选中多行数据时,“修改”按钮应变回禁用状态(因为不能同时修改多条记录),而“批量删除”按钮应保持可用。

1. 完善 <template> 按钮与表格区域

首先,我们在 6.3.1 步骤预留的 <el-row><el-table> 区域中,添加完整的按钮和事件监听。

  • 按钮工具栏: 添加“修改”和“删除”按钮,并将其 :disabled 属性分别绑定到我们预先定义好的 singlemultiple 响应式变量。
  • 表格事件监听: 为 <el-table> 组件添加 @selection-change 事件监听器,并将其指向我们将要创建的 handleSelectionChange 函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!-- **文件路径**: src/views/course/course/index.vue -->

<!-- ...el-form... -->

<!-- 2. 操作按钮工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<!-- 新增按钮,后续章节实现 -->
<el-button
type="primary"
plain
icon="Plus"
@click="handleAdd"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<!-- 修改按钮:当 single 为 true 时禁用 -->
<el-button
type="success"
plain
icon="Edit"
:disabled="single"
@click="handleUpdate"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<!-- 删除按钮:当 multiple 为 true 时禁用 -->
<el-button
type="danger"
plain
icon="Delete"
:disabled="multiple"
@click="handleDelete"
>删除</el-button>
</el-col>
<!-- ...其他按钮... -->
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>

<!-- 3. 数据展示表格:添加 @selection-change 事件监听 -->
<el-table v-loading="loading" :data="courseList" @selection-change="handleSelectionChange">
<!-- ...el-table-column definitions... -->
</el-table>

<!-- ...pagination & el-dialog... -->

2. 实现 handleSelectionChange 状态同步函数

现在,我们在 <script setup> 区域内,编写处理表格选择变化的 handleSelectionChange 函数。这个函数是本节的逻辑核心。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// **文件路径**: src/views/course/course/index.vue (`<script setup>` 部分)

// ... imports and reactive state definitions ...

// ... getList, handleQuery, resetQuery functions ...

/**
* 多选框选中数据处理
* @param {Array} selection - Element Plus Table 组件在选择项变化时,会传递当前所有被选中行的数据组成的数组
*/
function handleSelectionChange(selection) {
// 1. 更新选中行的 ID 列表
// 我们使用 map 方法从 selection 数组中提取出每一项的 id,并存入 ids.value
ids.value = selection.map(item => item.id);

// 2. 更新 single 状态
// 当 selection 数组的长度不等于 1 时,single.value 为 true (禁用修改按钮)
// 只有当 selection.length === 1 时,single.value 才为 false (启用修改按钮)
single.value = selection.length != 1;

// 3. 更新 multiple 状态
// 当 selection 数组的长度为 0 (即 ! selection.length 为 true) 时,multiple.value 为 true (禁用删除按钮)
// 只要选中了至少一项,multiple.value 就为 false (启用删除按钮)
multiple.value = !selection.length;
}

// ... getList() initial call ...

3. 深度解析:UI 状态与数据状态的同步

通过以上代码,我们已经完美地实现了预期的交互效果。这背后体现了现代前端框架中一个核心的设计思想:UI 状态是数据状态的直接反映

技术问答
2025-11-03 17:45

我理解了代码的功能,但 single.value = selection.length != 1;multiple.value = !selection.length; 这种“反向”的逻辑判断感觉有点绕。为什么不定义成 isSingleSelected 然后在模板里写 :disabled="!isSingleSelected" 呢?

专家

这是一个非常好的问题,它涉及到代码的可读性和语义的直观性。若依在这里选择的命名方式,是站在 模板(Template) 的角度来思考的。

专家

HTML 的 disabled 属性,其本身的含义就是“当此表达式为 true 时,禁用该元素”。

专家

因此,将变量命名为 single(可理解为“非单选状态”)和 multiple(可理解为“非多选状态”),在模板中使用 :disabled="single",其语义可以直接解读为:“当处于‘非单选’状态时,禁用此按钮”。这与 disabled 属性的原生语义高度契合,形成了非常直观的“语义对仗”,降低了阅读模板代码时的心智负担。

专家

如果我们采用 isSingleSelected 的命名,模板就得写成 :disabled="!isSingleSelected"。虽然也能实现功能,但在阅读时就需要多一次“取反”的逻辑转换,直观性稍差。这体现了若依在前端代码规范上对于“模板可读性”的重视。

工作流程总结:

  1. 事件触发: 用户在表格的复选框上进行任何勾选或取消勾选的操作。
  2. 回调执行: Element Plus 的 <el-table> 组件立即触发 @selection-change 事件,并调用我们绑定的 handleSelectionChange 函数,同时将当前所有被选中行的数据 selection 作为参数传入。
  3. 状态计算: handleSelectionChange 函数内部不执行任何 DOM 操作,它只做纯粹的数据计算:根据传入的 selection 数组的长度,更新 idssinglemultiple 这三个 ref 变量的值。
  4. 响应式更新: 由于 singlemultiple 是响应式变量,当它们的值发生变化时,Vue 的响应式系统会自动侦测到这一变化。
  5. 视图同步: Vue 自动将变化同步到模板中与这两个变量绑定的地方——即“修改”和“删除”按钮的 :disabled 属性上,从而实现按钮状态的瞬间切换。

这个流程清晰地展示了数据驱动视图(Data-Driven View)的开发模式。我们作为开发者,始终在操作数据,而将繁琐的 UI 更新工作完全交由框架来处理。这是现代前端开发效率得以极大提升的关键所在。


6.3.5. 步骤五:功能实现:新增与修改

任务:
至此,我们的页面已经具备了数据读取和交互的能力。本步骤的核心任务是实现数据的“写”操作,即 C (Create) 和 U (Update)。我们将激活“新增”和“修改”按钮,并利用 <el-dialog> 组件构建一个可复用的表单,以完成以下功能:

  • 点击“新增”按钮,弹出一个空的表单供用户填写并提交。
  • 选中一条数据后点击“修改”按钮,弹出一个已回填该行数据的表单供用户编辑并提交。

1. 完善 <template> 对话框与按钮区域

我们首先在 6.3.1 步骤预留的 <el-dialog><el-row> 区域内,添加完整的表单结构和事件绑定。

  • 按钮绑定: 为“新增”和“修改”按钮绑定 @click 事件,分别指向 handleAddhandleUpdate 函数。
  • 对话框表单: 在 <el-dialog> 内部,使用 <el-form> 构建与 tb_course 表字段对应的表单项,并将每个表单控件与 form 响应式对象中的属性进行 v-model 双向绑定。
  • 提交与取消: 为对话框底部的“确定”和“取消”按钮绑定 @click 事件,分别指向 submitFormcancel 函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<!-- **文件路径**: src/views/course/course/index.vue -->

<!-- ...el-form for search... -->

<!-- 2. 操作按钮工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="Plus"
@click="handleAdd"
v-hasPermi="['course:course:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="Edit"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['course:course:edit']"
>修改</el-button>
</el-col>
<!-- ...other buttons... -->
</el-row>

<!-- ...el-table... -->
<!-- ...pagination... -->

<!-- 5. 添加或修改课程管理对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="courseRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="课程编码" prop="code">
<el-input v-model="form.code" placeholder="请输入课程编码" />
</el-form-item>
<el-form-item label="课程学科" prop="subject">
<el-select v-model="form.subject" placeholder="请选择课程学科">
<el-option
v-for="dict in course_subject"
:key="dict.value"
:label="dict.label"
:value="dict.value"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="课程名称" prop="name">
<el-input v-model="form.name" placeholder="请输入课程名称" />
</el-form-item>
<el-form-item label="价格" prop="price">
<el-input v-model="form.price" placeholder="请输入价格" />
</el-form-item>
<el-form-item label="适用人群" prop="applicablePerson">
<el-input v-model="form.applicablePerson" placeholder="请输入适用人群" />
</el-form-item>
<el-form-item label="课程介绍" prop="info">
<el-input v-model="form.info" placeholder="请输入课程介绍" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>

v-hasPermi 是若依的权限控制指令。它会在组件挂载时检查当前用户是否拥有指定的权限标识(如 'course:course:add')。如果没有,该指令会直接将按钮从 DOM 中移除,这是一种比 v-if 更彻底的前端权限控制方案。

2. 实现新增与修改的核心逻辑函数

现在,我们在 <script setup> 区域内,编写驱动整个新增/修改流程的所有逻辑函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// **文件路径**: src/views/course/course/index.vue (`<script setup>` 部分)

// ... imports and reactive state definitions ...
// ... getList, handleQuery, resetQuery, handleSelectionChange functions ...

// 表单重置函数
function reset() {
form.value = {
id: null,
code: null,
subject: null,
name: null,
price: null,
applicablePerson: null,
info: null,
createTime: null,
updateTime: null
};
proxy.resetForm("courseRef");
}

// 对话框取消按钮
function cancel() {
open.value = false;
reset();
}

/** 新增按钮操作 */
function handleAdd() {
// 1. 重置表单,清除上一次的残留数据
reset();
// 2. 打开对话框
open.value = true;
// 3. 设置对话框标题
title.value = "添加课程管理";
}

/** 修改按钮操作 */
function handleUpdate(row) {
// 1. 重置表单,为加载新数据做准备
reset();
// 2. 确定要修改的数据 ID。如果 row 有值(来自行内操作按钮),则用 row.id;否则用 ids.value [0](来自顶部工具栏按钮)
const _id = row.id || ids.value[0];
// 3. 调用 API 获取该 ID 的详细数据
getCourse(_id).then(response => {
// 4. 将返回的数据回填到 form 对象中
form.value = response.data;
// 5. 打开对话框
open.value = true;
// 6. 设置对话框标题
title.value = "修改课程管理";
});
}

/** 提交按钮 */
function submitForm() {
// 1. 对整个表单进行校验
proxy.$refs["courseRef"].validate(valid => {
// 2. 如果校验通过
if (valid) {
// 3. 判断是修改还是新增操作(关键:通过 form.value.id 是否存在)
if (form.value.id != null) {
// 修改操作
updateCourse(form.value).then(response => {
proxy.$modal.msgSuccess("修改成功");
open.value = false;
getList(); // 刷新列表
});
} else {
// 新增操作
addCourse(form.value).then(response => {
proxy.$modal.msgSuccess("新增成功");
open.value = false;
getList(); // 刷新列表
});
}
}
});
}

// ... getList() initial call ...

3. 深度解析:对话框复用与表单提交流程

对话框复用模式 (Dialog Reuse Pattern):
这是中后台系统中非常经典和高效的设计模式。我们没有为“新增”和“修改”创建两个独立的对话框,而是通过几个状态变量 (open, title, form) 来控制同一个 <el-dialog> 组件的行为,其优势显而易见:

  • 代码量减半: 避免了大量重复的模板和样式代码。
  • 维护性更高: 当表单结构需要调整时,只需修改一处地方。
  • 逻辑清晰: handleAddhandleUpdate 两个入口函数职责分明,一个负责清空表单,一个负责填充表单,而最终的提交逻辑则由 submitForm 统一处理。

submitForm 的核心逻辑:
submitForm 函数是整个流程的“决策者”,其内部通过 form.value.id != null 这一简单而可靠的判断,来区分是执行更新操作还是创建操作。

  • handleAdd 流程进入时,reset() 函数已将 form.value.id 设为 null,因此会调用 addCourse API。
  • handleUpdate 流程进入时,getCourse API 返回的数据中包含了 id,因此会调用 updateCourse API。

若依工具集成: 在 submitForm 函数中,我们使用了 proxy.$modal.msgSuccess("...") 来显示操作成功的提示。这是若依对 Element Plus 的 ElMessage 组件的便捷封装。通过将其挂载到全局 proxy 上,我们无需在每个需要消息提示的组件中都手动 import { ElMessage } from 'element-plus',显著提升了开发效率和代码的整洁度。

至此,我们的“课程管理”模块已经具备了完整的 C (Create)、R (Read)、U (Update) 功能。用户不仅可以查看和筛选数据,还可以对其进行创建和修改,模块的核心价值已经基本实现。

6.3.6. 步骤六:功能实现:删除与批量删除

任务:
现在,我们的模块已经具备了完整的 C(Create)、R(Read)、U(Update) 功能。本步骤的任务是完成 CRUD 闭环的最后一块拼图:D (Delete)。我们将激活列表中的“删除”按钮和顶部工具栏的“删除”按钮,以实现以下目标:

  • 用户可以点击任一行数据后的“删除”按钮,删除单条记录。
  • 用户可以勾选多行数据,然后点击顶部工具栏的“删除”按钮,一次性批量删除多条记录。
  • 在执行删除这一破坏性操作前,必须向用户提供一个明确的、可反悔的二次确认对话框。

1. 完善 <template> 删除按钮区域

我们首先在 6.3.1 步骤预留的位置,添加完整的删除按钮及其事件绑定。

  • 行内删除按钮: 在 <el-table-column> 的操作列中,添加一个“删除”按钮,并通过 scope.row 将当前行数据传递给 handleDelete 函数。
  • 批量删除按钮: 顶部工具栏的“删除”按钮已在 6.3.4 步骤中添加并绑定了 :disabled 状态,现在我们为其 @click 事件绑定 handleDelete 函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- **文件路径**: src/views/course/course/index.vue -->

<!-- ...el-form for search... -->
<!-- ...el-row for buttons (Delete button already exists here)... -->

<!-- 3. 数据展示表格 -->
<el-table v-loading="loading" :data="courseList" @selection-change="handleSelectionChange">
<!-- ...other el-table-column... -->

<!-- 操作列 -->
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<!-- 修改按钮,已存在 -->
<el-button
link
type="primary"
icon="Edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['course:course:edit']"
>修改</el-button>
<!-- 行内删除按钮 -->
<el-button
link
type="primary"
icon="Delete"
@click="handleDelete(scope.row)"
v-hasPermi="['course:course:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>

<!-- ...pagination & el-dialog... -->

2. 实现 handleDelete 核心逻辑函数

现在,我们在 <script setup> 区域内,编写处理删除操作的核心函数 handleDelete。这个函数巧妙地复用了逻辑,以同时支持单行删除和批量删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// **文件路径**: src/views/course/course/index.vue (`<script setup>` 部分)

// ... imports and all previous functions ...

/** 删除按钮操作 */
function handleDelete(row) {
// 1. 确定要删除的数据 ID
// 如果 row.id 存在,说明是用户点击了行内的删除按钮,_ids 就是单个 id。
// 如果 row.id 不存在,说明用户点击了顶部的批量删除按钮,_ids 就是我们之前在 handleSelectionChange 中收集好的 ids.value 数组。
// 这个 || (或) 操作符优雅地处理了两种情况。
const _ids = row.id || ids.value;

// 2. 弹出二次确认对话框
// 使用若依全局封装的 $modal.confirm 方法
proxy.$modal.confirm('是否确认删除课程管理编号为"' + _ids + '"的数据项?').then(function() {
// 3. 用户点击“确定”后,执行 API 调用
return delCourse(_ids);
}).then(() => {
// 4. API 调用成功(即 then 链继续执行),刷新列表并显示成功提示
getList();
proxy.$modal.msgSuccess("删除成功");
}).catch(() => {
// 5. 用户点击“取消”或 API 调用失败,Promise 会进入 catch 块
// 在这里我们不需要做任何事,对话框会自动关闭。
});
}

// ... getList() initial call ...

3. 深度解析:逻辑复用与交互体验

逻辑复用 (const _ids = row.id || ids.value;):
这一行代码是 handleDelete 函数设计的精髓所在。

  • 当用户点击行内删除按钮时,Element Plus 会将该行的数据对象 row 作为参数传给 @click 的处理函数。此时,row.id 是一个具体的值(例如 15),row.id || ids.value 的结果就是 15delCourse(15) 将被调用。
  • 当用户点击顶部工具栏的删除按钮时,我们没有传递任何参数给 @click,所以 rowundefined。此时 row.idundefined(一个 falsy 值),row.id || ids.value 的结果就是 ids.value(例如 [1, 5, 8])。delCourse([1, 5, 8]) 将被调用。
  • 后端的 delCourse 接口被设计为既能接受单个 id 也能接受 id 数组,从而完美地支持了前端的这种统一调用。

交互体验 (proxy.$modal.confirm):
对于删除这样的高风险、不可逆操作,提供一个“安全缓冲带”——即二次确认对话框——是用户体验设计的基本准则。

技术深度追问
2025-11-03 18:00
学习者

我看到 handleDelete 的逻辑使用了 .then().then().catch() 这种 Promise 链式调用。为什么要这样写?

专家

这是处理异步操作(特别是需要用户交互的异步操作)的经典模式,非常清晰且健壮。让我们分解一下:

专家

proxy.$modal.confirm(...) 返回一个 Promise。如果用户点击“确定”,这个 Promise 会 resolve;如果用户点击“取消”或关闭对话框,它会 reject。

专家

第一个 .then(function() { return delCourse(_ids); }): 只有在用户点击“确定”后,这个回调才会执行。它的核心任务是发起真正的删除 API 请求 delCourse(_ids)关键点 在于它 returndelCourse 的调用结果,而 delCourse 本身也返回一个 Promise。这样,就把 API 请求的 Promise 串联到了整个链条上。

专家

第二个 .then(() => { ... }): 这个回调的执行时机是,当且仅当 delCourse 的 Promise 成功 resolve 时(即后端成功删除了数据并返回了成功响应)。这时我们才去刷新列表和提示成功,这是最准确的时机。

专家

.catch(() => {}): 这个 catch 会捕获两种失败情况:一是用户在第一步点击了“取消”,二是 delCourse 的 API 请求失败了。在这两种情况下,我们都不需要做特殊处理,所以提供一个空的 catch 块来“静默处理”这个 rejection,避免在控制台出现不必要的 Uncaught (in promise) 错误。

学习者

明白了,这种链式结构将“用户确认”、“API 请求”、“成功后处理”和“失败/取消处理”这四个环节清晰地串联了起来,代码逻辑非常线性,易于理解和维护。

至此,我们的“课程管理”模块已经完成了全部的 CRUD(增删改查)功能。它已经是一个功能完整、交互健壮的业务模块。在下一节,我们将为其添加“导出”等辅助功能,让其更加完善。

6.3.7. 步骤七:功能实现:数据导出

任务:
作为本模块的收尾工作,我们将实现一个常见且实用的辅助功能:数据导出。我们将激活顶部工具栏的“导出”按钮,当用户点击该按钮时,前端会请求后端接口,将当前查询条件下的所有数据以 Excel 文件的形式下载到用户的本地计算机。

1. 完善 <template> 导出按钮区域

我们首先在 6.3.1 步骤预留的 <el-row> 区域中,添加“导出”按钮并绑定其事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- **文件路径**: src/views/course/course/index.vue -->

<!-- ...el-form for search... -->

<!-- 2. 操作按钮工具栏 -->
<el-row :gutter="10" class="mb8">
<!-- ...新增, 修改, 删除 buttons... -->
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="Download"
@click="handleExport"
v-hasPermi="['course:course:export']"
>导出</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>

<!-- ...el-table, pagination, el-dialog... -->

2. 实现 handleExport 核心逻辑函数

现在,我们在 <script setup> 区域内,编写处理导出操作的核心函数 handleExport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// **文件路径**: src/views/course/course/index.vue (`<script setup>`部分)

// ... imports and all previous functions ...

/** 导出按钮操作 */
function handleExport() {
// 调用若依全局封装的下载方法
proxy.download('course/Course/export', {
// 传递当前所有查询参数
...queryParams.value
}, `course_${new Date().getTime()}.xlsx`);
}

// ... getList() initial call ...

3. 深度解析:若依的 proxy.download 方法

handleExport 函数的实现异常简洁,只有一行代码。这是因为若依框架将复杂的文件下载逻辑封装到了全局的 proxy.download 方法中。让我们揭开这个“黑盒”,理解其内部的工作机制。

技术深度追问
2025-11-03 18:15

proxy.download 这个函数做了什么?它为什么需要三个参数?

专家

proxy.download(url, params, filename) 是若依封装的一个专门用于处理后端文件下载请求的工具函数。它极大地简化了前端处理文件流的复杂性。让我们逐一解析它的三个参数和内部流程:

专家

1. url (必需): 第一个参数,字符串类型,代表后端提供文件下载的接口路径。在这里是 'course/course/export'

专家

2. params (可选): 第二个参数,一个对象,代表需要传递给后端接口的查询参数。我们使用了 { ...queryParams.value } 这个 ES6 的扩展运算符,它会创建一个 queryParams.value 对象的浅拷贝。这意味着,如果用户在页面上设置了搜索条件(如课程学科为“JavaEE”),那么导出请求也会带上这个条件,后端就会只导出符合条件的数据。

专家

3. filename (可选): 第三个参数,字符串类型,代表下载到本地后,你希望文件保存的名称。我们使用了 course_${new Date().getTime()}.xlsx 这种模板字符串,动态地生成一个带时间戳的唯一文件名,避免了文件名冲突。

那它内部具体是怎么实现下载的呢?

专家

proxy.download 内部的核心逻辑大致如下:

专家

它会使用 axios 向指定的 url 发起一个 POST 请求(通常文件导出用 POST 更合适,可以携带更多参数),并将 params作为请求体。最关键的一步是,它会在 axios 的配置中设置 responseType: 'blob'

专家

responseType: 'blob' 告诉 axios,期望服务器返回的是二进制数据(文件流),而不是通常的 JSON 文本。axios 会将接收到的二进制数据封装成一个 Blob 对象。

专家

函数会在内存中创建一个隐藏的 <a> 标签。

专家

它使用 URL.createObjectURL(blob) 将 Blob 对象转换成一个临时的、唯一的 URL,这个 URL 指向内存中的文件数据。

专家

将这个临时 URL 赋值给 <a> 标签的 href 属性,并将 filename 赋值给 download 属性。然后,通过 JavaScript 代码模拟用户点击了这个 <a> 标签。

专家

浏览器的默认行为会响应这个点击,弹出一个文件保存对话框,文件名就是我们指定的 filename

专家

下载完成后,函数会调用 URL.revokeObjectURL() 释放之前创建的临时 URL,以回收内存。

明白了,所以 proxy.download 实际上是为我们自动化了“请求二进制流 -> 创建 Blob -> 生成临时 URL -> 模拟点击下载”这一整套标准的浏览器文件下载流程。

通过本步骤的实现,我们的“课程管理”模块已经成为一个功能高度完备的业务单元,不仅囊括了核心的 CRUD 操作,还提供了数据导出这样的高级辅助功能。至此,index.vue 文件的手动构建工作已全部完成。