Ruo-Yi 前后端分离框架快速入门:核心功能(用户管理 / 菜单配置 / 数据表格)实战,快速掌握开发技巧

第一章. 若依极速搭建

摘要: 在本章中,我们将完成若依现代化开发环境的搭建,这是我们整个学习之旅的基石。我们将明确技术选型,直接从官方最新的 Spring Boot 3 + Vue 3 版本入手。随后,我们会详细介绍环境准备、项目克隆、数据库初始化以及配置文件修改等关键步骤。本章的唯一目标,是在 15 分钟内,让前后端项目在您的本地计算机上成功运行起来,并能顺利登录系统主界面。


在本章中,我们将像搭建乐高一样,遵循清晰的步骤,快速构建起我们的开发环境:

  1. 首先,我们将明确要使用的 若依版本,确保我们站在现代技术的最前沿。
  2. 接着,我们会校验并准备好所有必需的 开发环境,这是保证项目顺利运行的前提。
  3. 然后,我们将一步步 启动后端服务,包括克隆代码、初始化数据库和修改配置。
  4. 最后,我们将 启动前端服务,并完成首次登录,亲眼见证我们的工作成果。

1.1. 版本选择

在开始之前,了解若依的版本生态至关重要,这能帮助我们理解为何做出当前的选择。

1.1.1. 官方与社区版本概览

若依框架为了适应不同的开发场景,官方和社区共同构建了一个丰富的产品矩阵。主要包括:

  • RuoYi (混合版): 传统的 Spring Boot + Thymeleaf 架构,前后端代码耦合。
  • RuoYi-Vue (分离版): 主流的前后端分离架构,官方长期维护 Vue 2 版本。
  • RuoYi-Cloud (微服务版): 基于 Spring Cloud Alibaba 的微服务架构。
  • RuoYi-App (移动端版): 使用 Uniapp 开发跨平台移动应用。
  • 社区增强版: 如 RuoYi-Vue3 (升级前端到 Vue 3/Vite) 和 RuoYi-Vue-Plus (增强后端功能) 等。

1.1.2. 我们的选择:官方 Spring Boot 3 分支

为了紧跟技术潮流并获得最佳的开发体验,单纯的官方 RuoYi-Vue 主分支(基于 Spring Boot 2 和 Vue 2)已不能满足我们的要求。

幸运的是,若依官方已经提供了一个集成了 Spring Boot 3 和 Vue 3 的现代化分支。我们将直接使用这个分支作为起点,它一步到位地解决了前后端的技术升级问题。

文件路径: https://gitee.com/y_project/RuoYi-Vue/tree/springboot3

这让我们能跳过所有繁琐的迁移和配置,直接开始享受现代化技术栈带来的开发便利。


1.2. 环境准备

请确保您的本地开发环境满足以下最低要求。版本不匹配是导致后续问题的最常见原因。

官方推荐本教程版本
JDK >= 1.8JDK 17 LTS (Spring Boot 3 必需)
Mysql >= 5.7.0MySQL 8.x
Redis >= 3.0Redis 6.x
Maven >= 3.0Maven 3.6+
Node >= 12Node 16+

1.3. 运行后端项目

在上一节我们明确了目标版本后,现在,让我们正式开始克隆项目并启动后端服务。

1.3.1. 克隆项目源码 (直达 Spring Boot 3 分支)

我们将使用 Git 克隆包含前后端所有文件的指定分支。

在您的工作目录下,打开终端并执行以下命令:

1
2
# 克隆项目,并直接切换到 springboot3 分支
git clone -b springboot3 https://gitee.com/y_project/RuoYi-Vue.git ruoyi-modern-project

命令执行完毕后,您会得到一个名为 ruoyi-modern-project 的文件夹。请使用 IntelliJ IDEA 打开这个项目。

1.3.2. 初始化数据库

后端服务的运行依赖于预设的数据库表结构和数据。

  1. 请先在您的 MySQL 中创建一个新的数据库,推荐命名为 ry-vue
  2. 找到项目根目录下的 sql 文件夹,将 quartz.sqlry_2023xxxx.sql 这两个 SQL 文件,依次导入到您刚刚创建的 ry-vue 数据库中。
    若依项目 SQL 目录-需要导入的两个 SQL 文件被高亮

1.3.3. 修改配置文件

接下来,我们需要告诉项目如何连接到我们的数据库和 Redis。

  • 修改数据库连接
    文件路径: ruoyi-admin/src/main/resources/application-druid.yml
    请根据您的实际情况,修改 master 节点下的 url, username, password

image-20251101085023559

  • 修改 Redis 连接(可选)
    文件路径: ruoyi-admin/src/main/resources/application.yml
    请根据您的实际情况,修改 redis 节点下的 host, port, password

1.3.4. 启动后端服务

所有准备工作就绪,让我们启动后端。

  1. 在 IDEA 中,找到 ruoyi-admin 模块。
  2. 文件路径: ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java
  3. 右键点击该文件,选择 Run 'RuoYiApplication'

如果控制台成功打印出若依的 LOGO 和启动成功的字样,恭喜,后端服务已成功运行!
IDEA 中 RuoYiApplication 成功启动并显示若依 LOGO 的控制台截图

Windows 用户注意: 首次启动若 logback.xml 报错,请将 <property name="log.path" value="/home/ruoyi/logs" /> 修改为相对路径 <property name="log.path" value="logs" />

image-20251101085221246


1.4. 运行前端项目

后端大脑已经开始运转,现在我们来点亮前端的“眼睛”。

1.4.1. 定位前端项目

我们克隆的 ruoyi-modern-project 文件夹中,已经包含了前端项目,其目录名为 ruoyi-ui,但他是 vue2 版本的,我们期望使用一个技术栈较新的 vue3 版本,我们可以将整个 ruoyi-ui 文件夹删除,在我们的 ruoyi-modern-project 文件夹中克隆前端 Vue3 项目,我们将会得到 RuoYi-Vue3

这个文件夹,通过 vscode 或其他编译器打开确认版本号无误后进行后续操作

image-20251101085910513

1.4.2. 安装依赖并启动

  1. 使用终端进入前端项目目录:

    1
    cd ./RuoYi-Vue3
  2. 安装所有 Node.js 依赖

    1
    2
    3
    4
    5
    6
    7
    # 安装依赖
    pnpm install
    # 注意,由于前端的变化,vue3 仓库已经距离 2025 是三年前的了,一些 js 库依赖 sortablejs,所以需要手动安装,否则无法启动!
    pnpm install sortablejs
    # 启动 Vite 开发服务器:
    # 启动服务
    pnpm dev

    1.4.3. 验证成功

当终端显示出本地访问地址时,表示前端服务已成功启动。

打开浏览器,访问 http://localhost:80 (或终端提示的其他端口),输入默认账户/密码 admin/admin123 并登录。如果您能看到功能齐全的后台主界面,则代表整个环境已搭建成功!
若依 Vue3 版本成功登录后的主界面动图


1.5. 本章小结

在本章中,我们高效地完成了所有基础准备工作,为后续的学习扫清了障碍。

  • 明确了技术选型:我们直接采用了官方提供的 Spring Boot 3 + Vue 3 现代化技术栈。
  • 成功运行了项目:通过克隆代码、初始化数据库和修改配置,我们成功地在本地启动了前后端服务,并完成了首次登录。

现在,一个强大、现代化的开发框架已经在我们手中。在下一章,我们将立即体验它最激动人心的功能——代码生成器。


第二章. 功能体验:5 分钟生成一个模块

摘要: 在本章中,我们将亲自体验若依框架最核心、最激动人心的功能——代码生成器。我们将以一个真实的“课程管理”模块为案例,完整地走完从数据库设计到前后端功能自动生成的全过程。本章的目标是让您在不编写一行 Java 或 Vue 业务代码的情况下,亲手“创造”出一个功能完备的 CRUD 模块,从而最直观地感受若依作为“企业级快速开发平台”的惊人效率。


2.1. 需求分析与数据库准备

在软件开发的传统流程中,当我们接到一个新的功能需求时,通常需要经历漫长的设计、编码、联调过程。但使用若依,我们的工作模式将发生根本性的转变。若依的代码生成器是 “数据库驱动” 的,这意味着我们的首要任务,也是最关键的一步,是精确地定义好数据库的表结构。这张表就是我们未来整个模块的“DNA”,它决定了实体类的属性、后端接口的字段,乃至前端页面的表单项和表格列。

2.1.1. 明确我们的目标

我们的目标是创建一个功能完善的“课程管理”模块,它需要具备以下核心功能点:

  • 列表展示:能够分页、排序、清晰地展示所有课程的核心信息。
  • 条件查询:支持根据课程编码、课程名称(模糊查询)、课程学科(下拉选择)等条件筛选数据。
  • 数据操作:支持新增、修改、删除(单个或批量)课程信息。
  • 数据导出:能够一键将当前查询结果导出为 Excel 表格。

最终,我们期望在系统中看到如下的功能界面:
课程管理功能最终效果的原型图或截图

2.1.2. 数据库先行:代码生成的“设计图纸”

现在,让我们来绘制这张“设计图纸”。请复制下面的 SQL 脚本,它不仅定义了 tb_course 表的结构,还预置了一些测试数据,方便我们生成代码后立刻进行功能验证。

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
-- ----------------------------
-- Table structure for tb_course
-- ----------------------------
DROP TABLE IF EXISTS `tb_course`;
CREATE TABLE `tb_course` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '课程ID',
`code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '课程编码',
`subject` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '课程学科',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '课程名称',
`price` int NULL DEFAULT NULL COMMENT '价格(元)',
`applicable_person` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '适用人群',
`info` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '课程介绍',
`create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '课程管理表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of tb_course
-- ----------------------------
INSERT INTO `tb_course` VALUES (1, 'cp123456', 'JavaEE', 'JavaSE基础', 199, '小白学员', 'JavaSE基础入门课程', 'admin', NOW(), 'admin', NOW(), NULL);
INSERT INTO `tb_course` VALUES (2, 'cp123457', 'JavaEE', 'JavaWeb', 188, '初级开发者', 'JavaWeb核心技术', 'admin', NOW(), 'admin', NOW(), NULL);
INSERT INTO `tb_course` VALUES (3, 'cp123458', 'Python', 'Python入门', 555, '小白学员', 'Python语言基础', 'admin', NOW(), 'admin', NOW(), NULL);
INSERT INTO `tb_course` VALUES (4, 'cp123459', 'Python', 'PythonWeb', 88, '初级开发者', '使用Django开发Web应用', 'admin', NOW(), 'admin', NOW(), NULL);
INSERT INTO `tb_course` VALUES (5, 'cp123460', 'HarmonyOS', '鸿蒙入门', 99, '小白学员', '鸿蒙应用开发基础', 'admin', NOW(), 'admin', NOW(), NULL);
INSERT INTO `tb_course` VALUES (6, 'cp123461', 'HarmonyOS', '鸿蒙商城实战', 59, '初级开发者', '鸿蒙分布式电商项目实战', 'admin', NOW(), 'admin', NOW(), NULL);

2.1.3. 逐行剖析 SQL:理解“设计图纸”的每一个细节

这段 SQL 看似简单,但其中每一个字段的定义都蕴含着为代码生成器服务的深意。让我们来逐一解读:

  • id bigint NOT NULL AUTO_INCREMENT COMMENT '课程ID': 这是标准的自增主键。bigint 保证了足够的存储空间,AUTO_INCREMENT 让数据库自动管理 ID 的递增。最重要的是 COMMENT '课程ID',这个 字段注释 至关重要,代码生成器会读取它,并将其作为后端实体类 Course.javaid 属性的 /** 课程ID */ 注释,以及前端 index.vue 页面表格中该列的表头 label="课程ID"

  • code varchar(32) ... COMMENT '课程编码': 这是一个业务编码字段。varchar(32) 定义了其类型和长度。同样,COMMENT '课程编码' 会被生成器用于生成各层代码的注释和前端标签。

  • subject varchar(32) ... COMMENT '课程学科': 这是我们的“课程学科”字段。后续在代码生成器的配置中,我们会将它与“数据字典”关联,使其在前端自动渲染为下拉选择框。

  • name varchar(64) ... COMMENT '课程名称': 课程的名称。我们会在生成器中将其查询方式配置为“模糊查询 (LIKE)”,以便用户可以只输入部分名称进行搜索。

  • create_by, create_time, update_by, update_time, remark: 这五个字段是若依的“公共字段”。代码生成器能智能识别它们。因为若依的后端实体类基类 BaseEntity.java 中已经包含了这些属性,所以生成器在生成 Course.java 时,不会重复定义这些字段,而是让 Course 类直接继承 BaseEntity 来获得它们。这使得我们的业务实体类非常干净,只包含纯粹的业务字段。

核心技巧: 精心编写数据库表和字段的 COMMENT 是高效使用若依代码生成器的第一秘诀。注释写得越规范,生成的代码就越符合预期,需要手动修改的地方就越少。注释即代码,注释即文档。

2.1.4. 执行与验证

  1. 执行 SQL:
    请使用您熟悉的数据库管理工具(如 Navicat, DataGrip, 或 MySQL Workbench)连接到我们在第一章创建的 ry-vue 数据库。然后,打开一个新的查询窗口,将上述完整的 SQL 脚本粘贴进去,并执行。

  2. 验证结果:
    执行成功后,请刷新您的数据库表列表。您应该能看到一张名为 tb_course 的新表。为了进一步确认,您可以执行一条查询语句 SELECT * FROM tb_course;,如果能看到我们预置的 6 条课程数据,则证明数据库准备工作已圆满完成。

Navicat 中成功创建 tb_course 表并显示其数据的截图

至此,我们已经为代码生成器提供了最关键的“原料”。这张精确定义的 tb_course 表,就像一份详细的建筑蓝图,已经准备就绪。在下一个小节中,我们将把这份蓝图“喂”给若依的代码生成器,亲眼见证它如何在几秒钟内为我们构建起一座功能齐全的“代码大厦”。


2.2. 代码生成:从导入到配置全流程

这个环节就像是工厂里的“总装车间”。我们将把原材料(数据表)送上生产线(导入),通过一系列精密的仪器(编辑配置)进行加工,最终产出标准化的成品(代码压缩包)。请跟随以下步骤,细致地完成每一步操作。

2.2.1. 步骤一:导入数据表,将“蓝图”送入“工厂”

首先,我们需要让若依系统“读取”到我们新创建的 tb_course 表。

  1. 导航至功能入口:请在您已经登录的若依系统后台界面中,点击左侧菜单栏,依次展开并点击 系统工具 -> 代码生成

  2. 执行导入操作:您会看到一个代码生成的主列表,目前可能还是空的。请点击页面右上角的 导入 按钮。
    代码生成界面-导入数据表 tb_course 的动图演示

  3. 选择目标数据表:点击“导入”后,系统会弹出一个名为“导入表”的窗口。这个窗口会实时扫描当前数据库,并列出所有 尚未被代码生成器管理 的数据表。在这里,您应该能清晰地看到我们刚刚创建的 tb_course 表。

  4. 确认导入:勾选 tb_course 表名前的复选框,然后点击窗口右下角的 确定 按钮。

操作完成后,弹窗关闭,您会发现代码生成的主列表中已经出现了一条新的记录,正是我们的“课程管理表”。这标志着我们的“蓝图”已经成功进入了“总装车间”,等待我们进行下一步的精细化配置。

2.2.2. 步骤二:编辑配置,为“代码”注入“灵魂”

这是整个代码生成过程中最关键、最需要投入精力的一步。在这里,我们不仅仅是在操作界面,更是在 “设计”我们未来的应用程序。我们将告诉若依,每个数据库字段在前端应该以何种形式展现、是否需要校验、是否支持查询等等。

找到 tb_course 表所在行,点击其最右侧操作列中的 编辑 按钮。页面会跳转到生成配置详情页,这里分为三个核心选项卡。

A. 配置【基本信息】选项卡

这个选项卡定义了生成代码的元数据,大部分信息若依已经为我们智能填充好了。

  • 表名称 (tb_course)、表描述 (课程管理表): 这两个信息直接从数据库表的 NAMECOMMENT 中读取,通常无需修改。
  • 实体类名称 (Course): 若依会自动根据表名(去掉前缀 tb_ 并转为驼峰命名法)生成。这是我们后端 domain 层实体类的名字,非常重要,一般保持默认即可。
  • 作者: 这里默认是 ruoyi建议修改为您自己的名字或团队名称。这个值会出现在所有生成代码文件的头部注释 /** @author 你的名字 */ 中,是体现代码归属的重要标识。
  • 备注: 可选填,会生成在类注释的额外说明中。

image-20251101091647935

观察与思考: 您会发现,“表描述”和“实体类名称”的智能填充,正是得益于我们在 2.1 节中规范的表命名 (tb_ 前缀) 和表注释。规范的数据库设计是高效开发的第一步。

B. 配置【字段信息】选项卡

这里是代码生成的“心脏”,我们对每一行字段的配置,都将精确地反映在最终的前后端代码中,请按照下列图片中的勾选法,我们会在后面详细讲解

让我们逐列来理解这些配置项的含义:

  • 字段描述: 来自数据库字段的 COMMENT,将成为前端页面表格项的 label

image-20251101091912964

  • 插入/编辑/列表/查询 (√):这四列是最常用的配置。它们是布尔开关,决定了该字段是否会出现在对应的场景中。
  • 插入: 勾选后,该字段会出现在“新增”弹窗的表单中。
    * 编辑: 勾选后,该字段会出现在“修改”弹窗的表单中。
    * 列表: 勾选后,该字段会出现在主页面的数据表格中。
    * 查询: 勾选后,该字段会出现在页面顶部的搜索表单中。

image-20251101093337521

下面的表格将 完全模拟 您在上图界面中的操作过程。每一行都代表一个需要我们特别关注的字段,并清晰地展示了应如何配置,以及 “为什么这么配置”

序号字段描述插入编辑列表查询查询方式必填显示类型配置讲解
1课程 ID=文本框主键:由数据库自动生成,不应 由用户手动填写或修改,因此 取消勾选“插入”和“编辑”。
4课程名称LIKE文本框核心字段:需要完整的增删改查功能。查询方式设为 LIKE 以支持 模糊搜索。作为关键信息,设为 必填
5价格 (元)=文本框核心字段:同样需要完整功能,并设为 必填,确保数据的完整性。
7课程介绍=文本域长文本字段:内容较长,不适合作为查询条件,因此 取消勾选“查询”。显示类型设为 文本域 以提供更好的输入体验。
3课程学科=下拉框分类字段:最适合用下拉框来约束用户输入,保证数据规范性。我们将在下一步为其关联数据字典。
9创建时间=日期控件公共字段:由系统自动记录,禁止 用户手动增改。在列表中显示,并可作为查询条件(通常用于日期范围搜索)。

(注:未在表格中列出的字段,如 codeapplicable_person 等,可保持代码生成器的默认勾选配置,即支持完整的插入、编辑、列表和查询功能。)


教学闭环的关键点: 您会注意到,在配置“课程学科”时,我们虽然将“显示类型”设置为了 下拉框,但最右侧的 “字典类型” 一列我们仍然保持 留空。这正是我们教学设计的核心环节。现在,它只是一个空的下拉框。在下一章,当我们学习完“数据字典”后,会亲自回来,像拼上最后一块拼图一样,为它关联上我们创建的字典类型,然后重新生成代码。您将亲眼见证,页面上的这个下拉框是如何被“注入灵魂”,神奇地展示出所有预设的学科选项的。

C. 配置【生成信息】选项卡

这里决定了生成的代码文件应该放在哪里,以及它们的命名规则。

  • 生成模板: 若依内置了 crud (单表,增删改查) 和 tree (树形结构) 等模板。我们是标准的单表操作,保持默认的 crud 即可。
  • 生成模块名: 非常重要。这个名字将作为后端代码的子包名和前端代码的目录名。我们将其设置为 course。最终生成的后端包会是 com.ruoyi.course,前端 API 和视图目录会是 api/courseviews/course
  • 生成业务名: 非常重要。它决定了代码中核心类名的前缀。我们将其设置为 Course (首字母大写)。最终生成的类会是 CourseController.java, ICourseService.java, Course.java 等。
  • 生成功能名: 用于代码注释和菜单名称。我们填写 课程管理
  • 上级菜单: 决定了新生成的菜单项在系统左侧菜单栏中的位置。我们暂时将其挂在 系统工具 目录下。
  • 生成路径: 默认为一个临时目录。强烈建议保持默认。这是一种安全机制,避免因配置错误直接覆盖您项目中的已有代码。我们总是先生成到临时目录,检查无误后再手动复制到项目中。

image-20251101094825981

完成以上所有配置后,点击页面最下方的 提交 按钮保存我们的配置。

2.2.3. 步骤三:预览与生成,收获“劳动”成果

回到代码生成主列表页面,我们的配置工作已经完成。

  1. 预览 (可选但推荐):点击 tb_course 表操作列中的 预览 按钮。系统会弹出一个窗口,里面以选项卡的形式展示了即将生成的 domain.java, controller.java, service.java, mapper.xml, vue/index.vue, js/api.js 等所有文件的完整代码。这是一个绝佳的“代码审查”机会,您可以快速检查:
    • Course.java 的字段和注释是否正确?
    • CourseController.java 的权限标识 (@PreAuthorize) 是否符合预期?
    • index.vue 的表单项和表格列是否与您的配置一致?预览能帮助您在生成代码前发现并修正配置错误,事半功倍。

image-20251101093756253

  1. 生成代码:确认预览无误后,回到主列表,勾选 tb_course 表前的复选框,然后点击页面顶部的 生成代码 按钮。

浏览器会自动下载一个名为 ruoyi.zip 的压缩文件。

至此,神奇的时刻已经发生!

这个小小的 ruoyi.zip 文件中,包含了我们刚刚通过一系列页面配置所“设计”出来的,一个功能完备、前后端分离、代码规范、权限严密的“课程管理”模块的全部源码。我们没有写一行业务代码,却已经完成了过去可能需要数天才能完成的工作。

这把新鲜出炉的“神兵利器”已经铸造完毕。在下一个小节中,我们将学习如何将其“开刃”——把这些代码文件精准地集成到我们的项目中,并激活它的全部功能。


2.3. 代码集成与功能激活

在上一节中,我们已经成功地将数据库表“设计图”通过若依的配置界面,转化为一个包含了完整前后端代码的 ruoyi.zip 压缩包。这就像我们订购了一套预制房屋的建材,所有墙体、门窗、电路都已按需生产完毕。现在,我们将进入“现场施工”阶段,把这些“建材”精准地拼装到我们项目的“地基”之上,并接通“水电”,让它真正“活”起来。

这个过程需要细致和精确,就像拼装一个精密的模型。每一步操作都对应着将一个代码“零件”安装到它在系统中的正确位置。我们将分三步走:安装后端“引擎”、安装前端“仪表盘”,最后安装连接两者的“控制电路”(菜单与权限)。

2.3.1. 第一步:解压与勘察,认识我们的“代码零件包”

首先,请在您的电脑上找到并解压刚刚下载的 ruoyi.zip 文件。解压后,您会看到一个结构清晰的文件夹,它就是我们接下来工作的“零件盒”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ruoyi/
├── main
│ ├── java
│ │ └── com
│ │ └── ruoyi
│ │ └── course # <-- 后端 Java 代码 (Controller, Service, Domain)
│ └── resources
│ └── mapper
│ └── course # <-- 后端 MyBatis XML 文件
├── sql
│ └── menu.sql # <-- 菜单与权限的 SQL 脚本
└── vue
├── api
│ └── course # <-- 前端 API 请求文件
└── views
└── course # <-- 前端页面 Vue 组件

这个目录结构就是若依项目结构的微缩模型,清晰地告诉了我们每个“零件”应该被安装到哪里。

2.3.2. 第二步:后端代码集成,安装“引擎”与“传动系统”

我们需要将后端的 Java 类和 MyBatis 的 XML 映射文件,复制到我们正在运行的后端项目中。

  1. 复制核心业务代码 (Java)

    • 源路径 (零件盒): ruoyi/main/java/com/ruoyi/course
    • 目标路径 (项目地基): ruoyi-modern-project/ruoyi-admin/src/main/java/com/ruoyi/

    操作:
    完整地复制 源路径下的 course 文件夹。然后,在您的 IDE (如 IntelliJ IDEA) 的项目目录树中,找到并进入目标路径,粘贴 刚才复制的文件夹。

    img

    验证:
    操作完成后,您的 ruoyi-admin 模块的项目结构中应该会立即出现一个新的包 com.ruoyi.course。您可以点开它,会看到 controllerdomainservice 等子包,以及 CourseController.java 等我们熟悉的类文件。

image-20240515194048039

配置与代码的映射: 请注意,这里生成的包名 ...ruoyi.coursemapper/course,以及里面的 CourseController.java 等文件名,都精确地对应了我们在上一节【生成信息】选项卡中配置的“模块名”(course)和“业务名”(Course)。这直观地展示了若依是如何将我们的配置指令转化为规范的代码结构的。

2.3.3. 第三步:前端代码集成,安装“仪表盘”与“操作界面”

接下来,我们来安装用户能直接看到和交互的前端部分。

将生成的前端 api 和 views 文件夹复制到 ruoyi-frontend 项目结构中的截图

  1. 复制 API 请求文件:

    • 源路径 (零件盒): ruoyi/vue/api/course
    • 目标路径 (项目地基): ruoyi-modern-project/ruoyi-ui/src/api/

    操作:
    复制源路径下的 course 文件夹,并将其粘贴到前端项目的 src/api/ 目录下。

    讲解:
    src/api 目录是前端项目统一管理所有后端接口请求的地方。我们刚刚复制进去的 course.js 文件,里面封装了 listCourse, addCourse 等函数,每一个函数都对应着对后端 CourseController 中一个接口的 AJAX 调用。

  2. 复制页面视图文件:

    • 源路径 (零件盒): ruoyi/vue/views/course
    • 目标路径 (项目地基): ruoyi-modern-project/ruoyi-ui/src/views/

    操作:
    复制源路径下的 course 文件夹,并将其粘贴到前端项目的 src/views/ 目录下。

    讲解:
    src/views 目录存放着项目所有的页面组件。我们复制进去的 index.vue 文件,就是我们在 2.2 节中通过各种配置“设计”出来的“课程管理”页面的完整 Vue 组件代码。

2.3.4. 第四步:激活菜单与权限,接通“控制电路”

至此,我们的“硬件”——前后端代码文件——已经全部安装到位。但是,如果您现在刷新系统,会发现根本找不到“课程管理”的入口。这是因为系统还“不认识”这个新功能。

这就好比我们为电脑装了一块新显卡,但没有安装驱动程序。menu.sql 文件,就是我们这个新模块的“驱动程序”。它负责告诉若依的权限系统(RBAC):

  • 有一个新菜单:它的名字叫“课程管理”。
  • 它的位置在哪里:它应该显示在“系统工具”菜单下面。
  • 如何访问它:点击它时,应该加载前端的 /course 路由,对应的是 views/course/course/index.vue 这个组件。
  • 谁能看到它:只有拥有 course:course:list 权限标识的角色,才能在菜单栏里看到它。
  • 它内部有哪些操作权限:这个页面里有新增、修改、删除等操作,它们分别对应 course:course:add, course:course:edit, course:course:remove 等权限标识。

操作:

  1. 找到 SQL 文件: 在解压后的 ruoyi/sql 目录中,找到 menu.sql 文件。
  2. 执行 SQL: 使用您的数据库管理工具,连接到 ry-vue 数据库,打开一个新的查询窗口,将 menu.sql 文件的全部内容复制进去,并执行。
    使用数据库工具执行 menu.sql 文件的截图

当这条 SQL 执行成功后,若依的 sys_menu 表中就写入了关于“课程管理”模块的所有元数据。系统现在已经完全“认识”了我们的新功能,并准备好了将其展示给拥有相应权限的用户。

我们的“施工”工作已经全部完成。所有“零件”各就各位,“电路”也已接通。在下一个,也是本章最后一个小节中,我们将按下“启动”按钮,亲眼见证我们从零开始、在几分钟内创造出的完整功能模块,是如何在系统中完美运行的。


2.4. 访问与功能验证

本节将对上一节集成的“课程管理”模块进行全面的功能验证。目标是确认所有自动生成的组件、接口及权限均已正确部署并按预期运行。我们将遵循标准的软件测试流程,对模块的 CRUD (Create, Retrieve, Update, Delete)、查询、分页及导出等核心功能进行系统性测试。

2.4.1. 系统环境重载

为确保应用程序能完全加载新集成的代码资源和数据库菜单配置,必须执行以下重启和刷新操作。

  1. 重启后端服务:在 IntelliJ IDEA 中,终止当前运行的 RuoYiApplication 进程,然后重新执行该主类以启动应用。此步骤旨在让 Spring IoC 容器能够扫描并注册新增的 com.ruoyi.course 包下的所有组件(如 Controller, Service),并让 MyBatis 框架加载 CourseMapper.xml 配置文件。

  2. 刷新前端应用:在浏览器中,执行强制刷新操作(Ctrl + RCmd + R)以重新加载若依的前端应用。此操作将触发前端路由和权限管理模块向后端重新请求菜单数据,从而加载我们在 sys_menu 表中新增的“课程管理”菜单项。在多数情况下,需要重新进行用户登录。

2.4.2. 模块入口验证

在完成环境重载和重新登录后,需要验证新模块的菜单入口是否已正确生成。

  1. 定位菜单项:在系统界面的左侧导航菜单中,展开“系统工具”父菜单。
  2. 确认菜单可见性:检查“系统工具”菜单下是否存在一个名为“课程管理”的子菜单项。该菜单项的存在,证明了 menu.sql 脚本已成功执行,并且当前登录的管理员角色拥有访问该菜单所需的 course:course:list 权限标识。

image-20251101101641977

  1. 访问模块页面:点击“课程管理”菜单项。主内容区应成功加载并渲染出课程管理模块的主界面。界面应包含顶部条件查询表单、中部数据表格(已加载并显示 tb_course 表中的 6 条初始数据)以及底部自分页组件。

image-20251101101700543

2.4.3. 功能点逐项测试

接下来,对模块的核心功能进行单元测试。

  1. 数据检索 (Retrieve) 功能测试

    • 模糊查询:在“课程名称”输入框中输入查询字符串 Java,点击“搜索”。预期结果:数据表格应通过异步请求刷新,仅显示 name 字段包含 Java 的数据行。此测试验证了我们在代码生成器中为 name 字段配置的 LIKE 查询方式已在后端 CourseMapper.xml 中正确生成并执行。
    • 重置功能:点击“重置”按钮。预期结果:所有查询条件输入框应被清空,数据表格应恢复显示所有初始数据。
  2. 数据创建 (Create) 功能测试

    • 点击“新增”按钮,系统应弹出一个模态对话框(Dialog)。
    • 表单校验:检查对话框内的表单元素是否与代码生成器的配置一致。确认“课程介绍”字段渲染为 textarea 组件,“课程名称”和“价格”字段为必填项(有相应的视觉提示)。
    • 数据提交:在表单中输入一组有效的新课程数据,点击“确定”按钮提交。
    • 结果验证:对话框应关闭,并显示“新增成功”的全局提示。数据表格应自动刷新,并在最后一页(或当前页,取决于排序方式)显示新创建的记录。
  3. 数据更新 (Update) 功能测试

    • 在数据表格中,选中一条记录前的复选框。
    • 点击“修改”按钮,系统应弹出模态对话框,并自动将所选记录的数据填充到表单的对应字段中。
    • 数据修改与提交:修改表单中的任意字段值(例如,修改价格),然后点击“确定”提交。
    • 结果验证:系统应提示“修改成功”。数据表格中对应记录的已修改字段应更新为新值。
  4. 数据删除 (Delete) 功能测试

    • 单行删除:点击任意数据行右侧操作列中的“删除”按钮。在系统弹出的确认对话框中确认操作。预期结果:该数据行应从表格中移除。
    • 批量删除:在数据表格中,选中多条记录前的复选框。点击页面顶部的“删除”按钮并确认。预期结果:所有被选中的数据行应从表格中移除。
  5. 数据导出 (Export) 功能测试

    • (可选)在顶部设置任意查询条件。
    • 点击“导出”按钮。
    • 结果验证:浏览器应下载一个 .xlsx 格式的 Excel 文件。打开该文件,验证其内容是否与当前数据表格中显示的数据完全一致(包括经过筛选的数据)。

测试结论:
若以上所有功能点的预期结果均已达成,则可以确认:代码生成器已成功创建了一个功能完备、前后端逻辑正确、权限集成无误的业务模块。这验证了若依“快速开发平台”通过自动化代码生成来提升开发效率的核心价值。


第三章. 核心功能详解

摘要: 本章将深入剖析支撑若依框架高效、安全运行的三大核心功能支柱:权限系统 (RBAC)数据字典菜单管理。我们将从理解 RBAC 的理论模型入手,通过实战创建一个受限角色来掌握其具体应用。接着,我们将聚焦于解决第二章遗留的“下拉框”问题,系统性地学习数据字典的创建与使用。最后,我们还会对其他重要的系统管理功能进行快速概览。学完本章,您将不再仅仅是若依的使用者,而是能够随心所欲配置其核心功能的管理者。


在本章中,我们将像研究一本精密仪器的说明书一样,逐一拆解若依的核心部件:

  1. 首先,我们将深入若依的“安全内核”——权限系统 (RBAC),理解它是如何实现“正确的人,做正确的事”的。
  2. 接着,我们将掌握若依的“数据中枢”——数据字典,学会如何用它来管理系统中的静态数据,并解决实际开发中的 UI 交互问题。
  3. 最后,我们会快速导览 参数配置日志管理 等其他常用功能,完善我们的知识体系。

3.1. 权限系统 (RBAC)

权限管理是任何企业级后台系统的基石。若依内置了一套功能强大且设计经典的权限模型——RBAC(Role-Based Access Control,基于角色的访问控制)。理解 RBAC,是理解若依乃至绝大多数后台系统安全设计的关键。

3.1.1. 理解 RBAC:用户、角色与权限的关系

RBAC 的核心思想非常简洁:不直接给用户分配权限,而是将权限赋予“角色”,再将“角色”赋予用户

想象一下,在一个公司里,我们不会对新员工“张三”说:“你可以使用打印机、可以访问 A 系统、可以审批 B 流程……”。而是直接告诉他:“你的岗位是‘市场专员’”。而“市场专员”这个角色,已经被预先设定好了一揽子完成其工作所必需的权限。

这种模式带来了巨大的管理优势:

  • 高效授权:当一个新员工入职时,我们只需给他分配一个或多个角色,他便立即拥有了这些角色的所有权限。
  • 批量变更:当“市场专员”的职责发生变化,需要增加一项新权限时,我们只需修改“市场专员”这个角色本身,所有拥有该角色的员工权限便会自动更新。
  • 职责清晰:角色本身就代表了一组业务职责,使得权限管理与组织的业务结构保持一致,易于理解和维护。

在若依系统中,这个模型由三个核心实体和两个关系实体构成:

  • 核心实体:

    • 用户 (User): 系统中的操作个体,如 admin, ry
    • 角色 (Role): 权限的集合,代表一种职责或岗位,如“超级管理员”、“普通用户”。
    • 菜单/权限 (Menu/Permission): 系统的可操作资源。在若依中,它被巧妙地统一到了“菜单管理”中。一个“菜单”项(如“课程管理”)代表了页面访问权限,而一个“按钮”项(如“课程新增”)代表了具体的操作权限。
  • 关系实体:

    • 用户-角色关系: 定义了哪个用户拥有哪个角色(一个用户可以有多个角色)。
    • 角色-菜单关系: 定义了哪个角色拥有哪些菜单和按钮的权限(一个角色可以有多个权限)。

这五个实体之间的关系,在若依系统里被定义为了如下这五张表:
RBAC 核心五张表(用户、角色、菜单、用户角色、角色菜单

这张图清晰地揭示了它们在数据库层面是如何通过 sys_user_rolesys_role_menu 这两张中间表,建立起多对多的关系的。当一个用户登录时,系统会:

  1. 根据用户 ID,在 sys_user_role 表中找到他所拥有的所有角色 ID。
  2. 根据这些角色 ID,在 sys_role_menu 表中找到这些角色所拥有的所有菜单/权限 ID。
  3. 系统将这些权限信息(通常是一组权限标识符,如 course:course:list)存入该用户的会话(或 Token)中。
  4. 当用户访问一个页面或点击一个按钮时,后端(通过 Spring Security 的 @PreAuthorize 注解)和前端(通过自定义的 v-hasPermi 指令)会检查用户的会话中是否包含所需的权限标识符,从而决定是否放行。

3.1.2. 实战:创建“课研专员”角色并分配权限

理论知识是基础,现在让我们通过一个具体的业务场景,将理论付诸实践。

场景需求:我们需要在系统中创建一个新的角色——“课研专员”。这个角色的工作人员,其核心职责是管理课程信息,因此他们 只需要“课程管理”模块的全部权限,而不能访问系统中的其他任何功能(如用户管理、角色管理等)。

我们将分三步来完成这个任务:

第一步:创建新角色

  1. 在若依后台,导航至 系统管理 -> 角色管理
  2. 点击左上角的 新增 按钮。
  3. 在弹出的“添加角色”窗口中,填写以下信息:
    • 角色名称: 课研专员
    • 权限字符: course_researcher (这是一个全局唯一的 Key,后端权限校验时会用到)
    • 显示排序: 输入一个数字,如 3
    • 状态: 保持“正常”。

image-20251101103100627

第二步:为角色分配菜单权限

  1. 在“菜单权限”的树形结构中,取消勾选 最顶层的“系统管理”、“系统监控”、“系统工具”等所有默认选中的权限。

  2. 展开 系统工具 目录,找到并 只勾选“课程管理” 这一项。当您勾选父菜单“课程管理”时,其下属的所有按钮权限(查询、新增、修改、删除、导出)也会被自动勾选。
    创建新角色并为其分配菜单权限的界面截图

  3. 点击 保存 按钮。

至此,“课研专员”这个角色已经被我们精确地赋予了它所需的所有、且仅有的权限。

第三步:创建新用户并关联角色

  1. 导航至 系统管理 -> 用户管理
  2. 点击 新增 按钮。
  3. 在“新增用户”页面,填写新用户的基本信息,例如:
    • 用户昵称: 小P
    • 用户名称: Prorise
    • 密码: 设置一个初始密码。
  4. 在页面的下半部分,找到 角色 分配区域。取消勾选 默认的“普通用户”角色,然后 只勾选我们刚刚创建的“课研专员”
  5. 点击 确定 保存用户。

image-20251101103353049

第四步:验证

所有配置已完成。现在,请退出当前的 admin 账户,使用我们刚刚创建的 Prorise 账户登录系统。

image-20251101103458422

登录成功后,观察左侧的菜单栏。您会发现,原本庞大的菜单树消失了,只剩下了一个“系统工具”目录,且该目录下只有一个可点击的“课程管理”菜单。尝试访问其他被隐藏的 URL,系统会提示权限不足。进入“课程管理”页面,所有的新增、修改、删除按钮均可正常使用。

实验结论:我们成功地通过 RBAC 模型,创建了一个职责明确、权限最小化的新角色,并将其赋予了新用户。这个过程没有修改任何代码,完全通过后台界面配置完成。这充分展示了若依权限系统的灵活性与强大功能。


3.2. 数据字典:让你的页面“活”起来

数据字典是企业级应用中一个看似简单但至关重要的功能。它负责集中管理系统中相对稳定、但又可能需要调整的“静态数据”或“枚举值”。

3.2.1. 什么是数据字典及其应用场景

想象一下,在一个系统中,有多少地方需要用到“状态”这个概念?

  • 用户有“正常”、“停用”两种状态。
  • 订单有“待支付”、“已支付”、“已发货”、“已完成”、“已取消”等多种状态。
  • 通知公告有“通知”、“公告”两种类型。

如果我们在代码的每个角落都硬编码这些值(比如 if (status == 0) 代表正常,if (status == 1) 代表停用),会带来一场灾难:

  • 可读性差:代码中充满了“魔法数字”(Magic Numbers),01 本身不具备任何业务含义,极难理解。
  • 维护困难:如果有一天,需要增加一种“锁定”状态 2,你需要大海捞针般地找出系统中所有与用户状态相关的代码,进行修改,极易遗漏。
  • 不一致性:前端页面、后端逻辑、数据库存储,三者对状态的定义可能产生偏差,导致难以追踪的 BUG。

数据字典正是为了解决这些问题而生。

它提供了一个统一的地方,让我们用业务人员能理解的语言,来定义这些静态数据。例如,我们可以创建一个名为 sys_user_status 的字典类型,在里面定义:

标签 (Label)值 (Value)
正常0
停用1

这样,在整个系统中:

  • 前端:可以调用接口获取这个字典,自动生成下拉框或单选框,用户看到的是“正常”、“停用”这样的友好文本。
  • 后端:在接收或返回数据时,处理的是 01 这样规范的值,同时也可以方便地将 0 转换为“正常”用于日志或展示。
  • 数据库:存储的是简洁的 01,节省空间且便于索引。

当需要增加“锁定”状态时,我们只需在数据字典管理界面新增一条记录 {"锁定", "2"},整个系统(如果设计得当)无需修改一行代码,就能自动适应这种变化。

若依的数据字典功能主要由两部分构成

  • 字典类型: 对字典进行分组,相当于一个“文件夹”。例如 sys_user_status (用户状态), sys_notice_type (公告类型)。每个字典类型都有一个全局唯一的 类型名称 (Dict Type)
  • 字典数据: 隶属于某个字典类型下的具体键值对。例如 sys_user_status 下的 {"正常", "0"}{"停用", "1"}

它们在数据库中对应两张核心表:sys_dict_type (字典类型表) 和 sys_dict_data (字典数据表),通过 dict_type 字段进行关联。
若依数据字典的两张核心表 sys_dict_type 和 sys_dict_data 的关系图

3.2.2. 实战:为“课程学科”创建并应用数据字典

现在,让我们运用数据字典的知识,来完成第二章未竟的事业。我们的目标是,将“课程管理”模块中“课程学科”的查询条件和新增/修改表单,都改造为下拉选择框。

这个过程将完美地形成一个“学习 -> 配置 -> 应用 -> 验证”的闭环,让您深刻体验若依“配置驱动开发”的魅力。

第一步:创建字典类型

  1. 在若依后台,导航至 系统管理 -> 字典管理

  2. 点击左上角的 新增 按钮。

  3. 在弹出的“添加字典类型”窗口中,填写以下信息:

    • 字典名称: 课程学科 (这是给人看的,方便理解)
    • 字典类型: course_subject (非常重要!这是给程序用的唯一标识符,必须是英文且唯一)
    • 状态: 保持“正常”。
    • 备注: 可选填,如“用于定义课程的所有学科分类”。
  4. 点击 确定 保存。

image-20251101104607175

第二步:添加字典数据

字典类型这个“文件夹”已经建好,现在我们往里面添加具体的“文件”。

  1. 在字典管理列表页,找到我们刚创建的“字典类型”这一行,点击其高亮的蓝色标签
  2. 页面会跳转到“字典数据”管理界面。点击左上角的 新增 按钮。
  3. 在“添加字典数据”窗口中,填写第一条数据:
    • 字典类型: 自动带入 course_subject
    • 数据标签: JavaEE (这是显示在下拉框中的文本)
    • 数据键值: 0 (这是提交到后端并存入数据库的值)
    • 显示排序: 1(用于下拉框选择项的排序顺序)
    • 状态: 正常
  4. 点击 确定 保存。
  5. 重复操作:请参照上一步,继续添加 PythonHarmonyOS 这两个学科。确保它们的“数据键值”也是对应的英文。

全部添加完成后,您应该能在 course_subject 的字典数据列表中看到三条记录。

image-20251101104733262

第三步:将数据字典应用到代码生成器

现在,我们的数据字典已经准备就绪。是时候回到第二章我们留下“伏笔”的地方,将这块“拼图”拼上了。

  1. 导航回到 系统工具 -> 代码生成
  2. 找到 tb_course (课程管理表) 这一行,点击 编辑
  3. 在编辑页面,切换到 字段信息 选项卡。
  4. 找到 subject (课程学科) 这一行,定位到最右侧的 字典类型 这一列。
  5. 点击该列的下拉框,您现在应该能在列表中找到我们刚刚创建的 course_subject选中它
    在代码生成器中为字段选择数据字典的截图
  6. 点击页面底部的 提交 按钮保存配置。

第四步:重新生成并集成代码

我们的“设计图纸”已经更新,现在需要让“工厂”按照新的图纸重新生产“零件”。

  1. 在代码生成主列表,重新勾选 tb_course 表。

  2. 再次点击 生成代码 按钮,下载一个新的 ruoyi.zip 文件。

  3. 解压这个新的压缩包。注意,这次我们不需要关心后端的 Java 代码或 SQL 文件,因为数据字典的应用主要体现在前端。

  4. 覆盖前端代码

    • 源路径: 新解压的 ruoyi/vue/views/course/index.vue
    • 目标路径: ruoyi-modern-project/ruoyi-ui/src/views/course/index.vue
    • 操作: 直接用新的 index.vue 文件,覆盖 掉项目中原有的同名文件。

课程管理页面,课程学科学段变为下拉框并成功显示字典数据

第五步:修正数据不匹配问题

在验证成果之前,我们需要解决一个关键问题:数据库中现有数据与数据字典的值不匹配。

回顾一下,在第二章初始化数据时,我们插入的课程数据中 subject 字段的值是 'JavaEE''Python' 等字符串。但在刚才创建数据字典时,我们将 “数据键值” 设置为了 012 这样的数字。

这会导致一个问题:当您打开 “课程管理” 页面时,现有的课程记录在 “课程学科” 列会显示为纯数字,因为系统无法将数据库中的 'JavaEE' 字符串匹配到字典值 0

为什么会有这个问题?

这是一个典型的 “历史数据兼容性” 问题。在实际项目中非常常见:

  • 设计初期:我们直接在数据库中存储了业务含义明确的字符串 'JavaEE',这样做简单直观。
  • 引入数据字典后:为了规范化和便于维护,我们改用数字编码 012 来存储,前端通过字典将其转换为友好的中文标签展示给用户。

解决方案有两种

方案一:修改数据字典的键值(推荐用于学习)

如果您的系统刚刚起步,数据量还很少,最简单的做法是调整数据字典,让它的 “数据键值” 与数据库现有数据保持一致。

  1. 返回 系统管理 -> 字典管理
  2. 点击 course_subject 字典类型,进入字典数据列表。
  3. 逐一编辑三条字典数据,将它们的 数据键值 修改为:
    • JavaEE 的数据键值改为 JavaEE (而不是 0
    • Python 的数据键值改为 Python (而不是 1
    • HarmonyOS 的数据键值改为 HarmonyOS (而不是 2
  4. 保存修改。

这样,数据字典的键值就与数据库中 subject 字段的实际存储值完全一致了,页面可以正常匹配显示。

方案二:修改数据库中的历史数据(推荐用于生产)

如果您希望数据库存储更规范的数字编码(这在大型系统中更常见),则需要更新数据库中的历史数据。

在 Navicat 或其他数据库工具中执行以下 SQL 语句:

1
2
3
UPDATE tb_course SET subject = '0' WHERE subject = 'JavaEE';
UPDATE tb_course SET subject = '1' WHERE subject = 'Python';
UPDATE tb_course SET subject = '2' WHERE subject = 'HarmonyOS';

执行后,数据库中的 subject 字段值就变成了 012,与我们最初创建的数据字典键值完全对应。

本教程采用方案二,因为它操作更简单,我们的测试数据很少,更适合初学者。在实际项目中,您可以根据具体情况灵活选择。

第六步:修复前端样式问题

覆盖完代码后,您可能会发现 “课程学科” 下拉框显示异常:它可能只显示为一个小方块,无法正常展开选择。这是因为代码生成器生成的 el-select 组件默认没有设置宽度,导致组件收缩。

我们需要手动为下拉框添加宽度样式。

打开项目中的 ruoyi-modern-project/RuoYi-Vue3/src/views/course/Course/index.vue 文件:

位置一:查询表单中的下拉框

找到第 12-21 行左右的查询表单部分,将:

1
2
<el-form-item label="课程学科" prop="subject">
<el-select v-model="queryParams.subject" placeholder="请选择课程学科" clearable>

修改为(在 el-select 标签中添加 style="width: 240px"):

1
2
<el-form-item label="课程学科" prop="subject">
<el-select v-model="queryParams.subject" placeholder="请选择课程学科" clearable style="width: 240px">

位置二:新增/编辑对话框中的下拉框

找到第 133-141 行左右的对话框表单部分,将:

1
2
<el-form-item label="课程学科" prop="subject">
<el-select v-model="form.subject" placeholder="请选择课程学科">

修改为(添加 style="width: 100%"):

1
2
<el-form-item label="课程学科" prop="subject">
<el-select v-model="form.subject" placeholder="请选择课程学科" style="width: 100%">

为什么要设置不同的宽度?

  • 查询表单是 内联布局 (inline="true"),各个表单项横向排列,设置固定宽度 240px 可以保持页面紧凑美观。
  • 对话框表单是 垂直布局,表单项纵向堆叠,设置 100% 宽度可以让下拉框填充整个表单项区域,与其他输入框保持视觉一致。

保存文件后,下拉框就能正常显示了。

第七步:验证成果

回到您的浏览器,强制刷新“课程管理”页面(可能需要重新登录)。

现在,请观察页面顶部的搜索栏和点击“新增”或“修改”按钮弹出的表单。您会惊喜地发现,原本的“课程学科”文本输入框,已经 神奇地变成了功能完善的下拉选择框!点击它,会清晰地列出我们在数据字典中配置的“JavaEE”、“Python”和“HarmonyOS”三个选项。

实验结论:我们成功地通过数据字典,以一种零编码、纯配置的方式,优化了一个重要的前端交互。这个完整的闭环流程充分证明了数据字典在解耦前后端数据、提升开发效率和系统可维护性方面的巨大价值。现在,您已经完全掌握了若依的这一核心功能。


3.3. 参数设置:系统的动态“开关”

在任何一个正式的软件项目中,我们都会遇到一类特殊的配置项:它们不像数据库地址那样恒定不变,但又不能直接硬编码在代码里,因为业务人员或运维人员可能需要根据实际情况随时调整。

参数设置 功能,正是为了解决这一痛点而设计的。它本质上是一个 存储在数据库中的、全局的键值对(Key-Value)配置中心。通过后台界面,我们可以方便地对这些参数进行动态维护。

使用该功能的核心优势在于:

  • 避免硬编码:将易变的业务逻辑值(如“密码最大重试次数”、“是否开启某项活动”)从代码中剥离,提高了代码的可维护性。
  • 运行时动态生效:修改参数后,通常只需刷新缓存即可在整个系统中生效,无需重新编译代码或重启服务,这对于生产环境的运维至关重要。
  • 配置集中化:所有动态参数都在一个统一的界面进行管理,一目了然。

3.3.1. 实战案例:动态关闭登录验证码

业务场景: 在项目开发和测试阶段,开发人员需要频繁地登录、退出系统进行调试。默认开启的登录验证码虽然增强了安全性,但在这个阶段却大大降低了调试效率。我们的需求是:在不修改任何代码的情况下,为开发环境动态地关闭登录验证码功能。

实现步骤:

  1. 导航至参数设置
    在若依后台,依次点击 系统管理 -> 参数设置。您会看到一个列表,其中包含了系统预设的多个参数。
    参数设置功能的主界面截图
  2. 定位目标参数
    在参数列表中,找到 参数键名sys.account.captchaEnabled 的记录。从键名我们可以直观地理解,这个参数就是用来控制“账户验证码是否启用”的。
  3. 修改参数值
    点击该行右侧操作列的 修改 按钮。在弹出的“修改参数”窗口中,将 参数键值true 修改为 false

  1. 清除缓存以使配置生效
    点击 确定 保存修改。此时,配置已经更新到了数据库中,但正在运行的系统为了性能,读取的是 Redis 缓存中的旧配置。因此,我们需要执行关键的最后一步:点击页面右上角的 “刷新缓存” 按钮。这个操作会通知系统从数据库重新加载所有配置项到 Redis 中。

  2. 验证结果
    现在,请退出当前 admin 账户。当您返回到登录页面时,会发现原先的验证码输入框和图片已经消失了,可以直接输入用户名和密码进行登录。

    验证码关闭后,登录页面不再显示验证码输入框的对比图

通过这个简单的案例,我们以一种零代码、纯配置的方式,动态地改变了系统的核心行为,这正是“参数设置”功能的强大之处。


3.4. 日志管理:系统操作的全程记录仪

日志是系统安全、问题排查和行为审计的生命线。若依提供了两个开箱即用的日志管理模块,它们就像是安装在系统内部的“黑匣子”和“监控摄像头”,忠实地记录着发生的一切。

3.4.1. 操作日志:行为审计与问题追溯

是什么?
操作日志精确记录了用户在系统中所有 对数据产生变更或执行重要查询 的关键操作。

背后原理:
该功能是通过 Spring AOP(面向切面编程) 和一个自定义的 @Log 注解 实现的。当一个 Controller 方法被 @Log 注解标记后,AOP 切面会自动拦截该方法的调用。在方法执行前后,切面会收集诸如 操作用户、IP 地址、请求 URL、调用方法、传入参数、操作耗时、是否成功 等信息,并将其异步地保存到数据库的 sys_oper_log 表中。

实战联动:追踪“课程管理”的操作记录

  1. 执行操作: 请您现在导航至 系统工具 -> 课程管理 模块。执行一次 新增 操作,成功添加一条新的课程记录。然后,再执行一次 删除 操作,将这条新记录删除。
  2. 查看日志: 操作完成后,立刻导航至 系统监控 -> 操作日志
  3. 分析结果: 在日志列表的顶部,您会看到两条最新的记录:
    • 一条记录的 业务类型“新增”请求方式POST。点击“详细”按钮,您甚至可以看到当时提交的完整 JSON 数据。
    • 另一条记录的 业务类型“删除”请求方式DELETE

image-20251101115201922

这个联动的过程清晰地展示了操作日志的价值:它为每一次关键操作都留下了不可否认的证据,无论是用于事后排查问题(“是谁误删了数据?”),还是进行安全审计,都至关重要。

3.4.2. 登录日志:守护系统安全的第一道防线

是什么?
登录日志专门、独立地记录每一次用户登录系统的尝试,无论成功与否。

核心价值:
它是系统安全审计的关键环节。通过分析登录日志,管理员可以:

  • 发现异常登录: 例如,在短时间内,同一个账户从多个不同的地理位置 IP 尝试登录,这极有可能是账户被盗或被攻击的迹象。
  • 监控失败尝试: 如果一个账户连续多次登录失败,系统可以触发告警或自动锁定该账户,防止暴力破解。

如何使用:
导航至 系统监控 -> 登录日志。这里详细列出了所有登录尝试的账号、状态(成功/失败)、IP 地址、登录地点、浏览器和操作系统信息。

image-20251101115337828


3.5. 通知公告:系统的“广播站”

是什么?
这是一个简单但非常实用的内容发布功能,允许管理员向系统内的用户发布结构化的信息。

核心功能:

  • 富文本编辑: 创建公告时,支持使用富文本编辑器,可以方便地设置文本格式、插入图片、超链接等。
  • 公告分类: 公告可以被分为不同的类型,如“通知”和“公告”。这个分类本身也是由 数据字典 (sys_notice_type) 维护的。

实战案例:发布一条版本更新通知

  1. 导航: 进入 系统管理 -> 通知公告
  2. 新增公告: 点击“新增”按钮。
  3. 填写内容:
    • 公告标题: 系统 V3.8.7 版本更新通知
    • 公告类型: 选择 通知
    • 公告内容: 在富文本编辑器中输入更新详情。

目前 ruoyi 普通版只有内置对于公告的 CRUD,对于页面来说他不会有任何变化,如果希望自己集成公告推送的话可以参考如下这篇文章:

[Ruoyi 若依通知公告功能实现(轮询信息铃铛)_若依消息通知-CSDN 博客](https://blog.csdn.net/TinpeaV/article/details/137146115?spm = 1001.2014.3001.5501)

这个功能虽然简单,但在需要向全体用户传达重要信息的场景(如系统维护、版本更新、节假日通知)下,非常高效。

3.6. 菜单管理:构建系统的导航骨架与权限节点

在 3.1 节讲解 RBAC 模型时,我们已经初步接触了“菜单”作为“权限”载体的概念。然而,那只是从角色分配的角度去理解。本节,我们将 完全聚焦于“菜单管理”功能本身,以系统构建者和架构师的视角,深入学习如何通过它来 手动 规划、构建和维护整个应用的导航体系,并为每一个可操作的“点”精确地定义权限。

这项技能至关重要,因为在任何实际的二次开发中,无论是添加一个全新的业务模块,还是想调整现有功能的布局,亦或是将一个大页面拆分为多个子页面,“菜单管理”都是您必须操作的第一站。

3.6.1.深度解析菜单、目录与按钮

若依的“菜单管理”是将 前端视图(Component)前端路由(Route)后端权限(Permission) 进行统一声明和绑定的核心枢纽。其设计由三种不同类型的“菜单”构成,每种类型都有其精确的角色和核心配置。

A. 目录 (Directory)

“目录”扮演着纯粹的 “组织容器” 的角色,用于组织一组相关的菜单,形成清晰的导航层级。

image-20251102153649880

关键配置 (UI 字段)描述与说明
上级菜单指定该目录所属的父节点。选择“主类目”则为顶级目录。
菜单图标为目录选择一个在导航栏中显示的图标。
菜单名称显示在导航栏中的文本,如“系统管理”。
路由地址核心配置。作为所有子菜单的 URL 路径前缀,必须唯一。
显示状态控制该目录及其下所有子项是否在导航栏中可见。

B. 菜单 (Menu)

“菜单”是用户能与之交互、通往具体功能页面的 “真正入口”

image-20251102153830017

关键配置 (UI 字段)描述与说明
组件路径核心配置。指向前端 src/views/ 目录下的 .vue 文件路径。
权限字符核心配置。连接前后端权限校验的 唯一凭证
路由参数(高级) 定义路由的动态参数,如 /user/profile/:userId
是否缓存控制该页面是否被 Vue 的 <keep-alive> 组件缓存,提升二次访问速度。

C. 按钮 (Button)

“按钮”是一种 “隐藏的权限节点”,专门用于定义页面内部的具体操作权限。

image-20251102153917601

关键配置 (UI 字段)描述与说明
上级菜单必须选择一个“菜单”类型的父节点,表明该按钮隶属于哪个页面。
权限字符核心配置。对应页面上某个按钮的 操作权限,后端用于注解,前端用于指令。
菜单名称用于在后台管理时识别此权限,如“用户新增”、“用户修改”。

3.6.2. 实战案例:手动创建并重组“课程管理”模块

业务场景: 将“课程管理”模块从“系统工具”中移除,并放置到一个全新的、名为 “教学管理” 的顶级模块下。

  • 第一步:创建新的顶级“目录” - 教学管理

    1. 导航: 系统管理 -> 菜单管理,点击 新增
    2. 配置: 参照下表完成目录的关键信息配置。
配置项 (UI 字段)填写值目的与说明
上级菜单主类目创建一个顶级的、没有父级的导航分组。
菜单类型目录声明这是一个组织容器,不可跳转。
菜单名称教学管理导航栏中显示的文本。
路由地址education定义该模块下所有子页面的 URL 路径前缀。
显示排序5控制在同级菜单中的显示顺序。
  • 第二步:创建“课程管理”页面“菜单”

    1. 清理: 先删除原“系统工具”下的“课程管理”相关菜单。
    2. 新增: 再次点击 新增
    3. 配置: 参照下表完成菜单的关键信息配置。
配置项 (UI 字段)填写值目的与说明
上级菜单教学管理将此菜单归属于新创建的“教学管理”目录下。
菜单类型菜单声明这是一个可点击、链接到页面的入口。
路由地址course定义页面的具体路径,最终 URL 为 /education/course
组件路径course/Course/index精确指定要加载的前端 Vue 组件文件。
权限字符course:course:list定义访问此页面的核心权限凭证。
是否缓存缓存开启页面缓存,提升用户体验。
  • 第三步:为新菜单添加“按钮”权限

    1. 新增: 再次点击 新增
    2. 配置: 参照下表,为“新增”操作创建按钮权限。
配置项 (UI 字段)填写值目的与说明
上级菜单课程管理将此按钮权限附属于“课程管理”页面。
菜单类型按钮声明这是一个页面内部的操作权限,不在导航栏显示。
菜单名称课程新增用于在后台管理时识别此权限。
权限字符course:course:add定义“新增”操作的唯一权限凭证。
  1. 重复操作: 参照上表,继续添加 修改 (course:course:edit)、删除 (course:course:remove) 等其他按钮权限。
  • 第四步:验证成果

    1. 操作: 点击右上角头像 -> 清除缓存,然后强制刷新浏览器页面。
    2. 观察: 左侧菜单栏出现新的“教学管理”顶级目录,展开后可点击“课程管理”并正常访问。

通过这个手动配置的过程,您已经掌握了如何运用“三位一体”的模型,自由地构建和调整若依系统的功能布局与权限体系。


3.7. 部门管理:构建企业的组织架构树

在完成了对菜单和权限的精细化定义后,我们转向企业管理的另一个核心维度——组织架构。“部门管理”功能正是若依框架中用于构建和维护这一结构的基础模块。

3.7.1. 是什么:超越通讯录的组织核心

“部门管理”远不止是一个简单的“公司通讯录”或部门列表。它是一个以 树形结构 来定义企业内部组织层级的核心功能,为实现复杂的、基于组织汇报关系的管理需求提供了数据基础。

image-20251102155158912

核心价值

部门管理本身只是一个组织结构的定义,但它的真正价值在于 与其他模块的联动,从而实现基于组织架构的数据权限控制和业务流程流转:

联动模块实现功能业务场景举例
用户管理用户归属每个用户必须归属于一个部门,这是构建“按部门筛选/统计用户”等功能的基础。
角色管理数据权限最重要的应用。可以为角色配置不同的数据范围,如“本部门数据权限”、“本部门及以下数据权限”等。
业务模块 (二次开发)数据隔离在 CRM、ERP 等系统中,可以实现销售只能看到本部门的客户,财务只能看到本分公司的账目。
工作流 (二次开发)流程审批定义审批流,如“员工提交 -> 部门经理审批 -> 总监审批”,审批人根据部门层级关系自动确定。

数据权限的背后原理

当一个角色被赋予了例如“本部门数据权限”后,若依的后端会通过 AOP 切面 在执行数据库查询时进行拦截。切面会获取当前登录用户的部门 ID,并 自动向正在执行的 SQL 语句动态拼接上 WHERE 条件(例如,AND u.dept_id = 103),从而在数据库层面就完成了数据的过滤,保证了数据权限的安全性与高效性。

他的最重要的用途是在 用户管理 页面提供筛选功能,可以通过筛选去查看不同部门的用户情况,

image-20251102161421229


第四章. 系统监控:项目的健康仪表盘(非重要)

摘要: 在本章中,我们将从开发者的角色,部分切换到运维工程师(SRE/DevOps)的视角,学习如何利用若依内置的强大监控工具集来保障应用的健康与稳定。我们将逐一探索 在线用户管理、服务监控、缓存监控数据监控(Druid) 等核心模块。学完本章,您将具备诊断应用性能、监控系统资源和排查潜在问题的基本能力,为项目的稳定运行提供坚实保障。


4.1. 在线用户:实时会话管理与安全控制

是什么?
“在线用户”功能提供了一个实时的会话监控界面,它展示了当前所有通过验证并活跃在系统中的用户会话列表。

核心价值:

  • 实时监控: 管理员可以清晰地看到每个在线用户的 登录账号、所属部门、登录 IP、登录地点、浏览器类型 以及 操作系统
  • 安全强制下线: 提供了“强退”功能。当发现异常或可疑会话时(例如,某员工已离职但其账号仍在异地登录),管理员可以一键强制该用户下线,使其 Token 失效,从而立即终止其访问权限。

实战场景:处理异常登录会话

  1. 模拟场景: 请您使用另一个浏览器(或浏览器的隐私模式),再次登录 admin 账户。现在,您的 admin 账户就拥有了两个独立的在线会话。
  2. 导航与观察: 在第一个浏览器中,导航至 系统监控 -> 在线用户。您会在列表中看到两条 admin 用户的登录记录,它们的“会话编号”不同,可能登录 IP 也不同(如果使用了代理)。
  3. 执行强退: 找到您在第二个浏览器中登录的那条会话记录,点击其右侧操作列的 强退 按钮。
  4. 验证结果: 回到第二个浏览器,尝试点击任意菜单或刷新页面。您会发现系统会立即提示“登录状态已过期,请重新登录”,并跳转到登录页。这证明该会话已被成功终止。
    在线用户列表及强退操作示意图

4.2. 服务监控:洞悉服务器与 JVM 状态

是什么?
服务监控是一个集成的服务器运行时信息展示面板,它利用了 oshi 这个库来实时获取服务器底层的硬件和 JVM 的各项性能指标。

核心指标解读:

  • CPU: 展示服务器 CPU 的核心数、系统使用率、用户使用率和当前空闲率。CPU 使用率持续过高 是系统性能瓶颈最直接的信号。
  • 内存: 显示总物理内存、已用内存和剩余内存。内存使用持续接近上限 可能预示着内存泄漏或需要增加硬件资源。
  • 服务器信息: 包括服务器名称、操作系统、IP 地址等。
  • Java 虚拟机信息: 这是排查 Java 应用问题的关键区域。
    • 内存: 重点关注 “堆内存” 的使用情况(如 Max, Committed, Used)。如果 Used 内存持续增长而不回落,即使在应用空闲时也是如此,这通常是 内存泄漏(Memory Leak) 的典型特征。
    • 线程: 显示当前 JVM 的总线程数、守护线程数等。过多的线程数会消耗大量内存和 CPU 资源。

如何使用:
该页面提供的信息主要用于性能监控和问题诊断。当用户反馈系统卡顿或响应缓慢时,服务监控页面是运维人员首先需要查看的地方,以快速判断问题是出在 CPU、内存还是应用本身。

image-20251101120441297


4.3. 缓存监控:深入 Redis 的世界

是什么?
缓存监控提供了一个对若依所使用的 Redis 缓存实例的详细信息的图形化展示界面。

核心价值:

  • 基本信息: 展示 Redis 的版本、运行模式(单机/集群)、端口、运行时间等。
  • 命令统计: 以图表形式展示 Redis 自启动以来执行过的各种命令(如 GET, SET, KEYS)的次数。这有助于分析应用的缓存使用模式。
  • 内存信息: 显示 Redis 已使用的内存、内存峰值、内存碎片率等关键指标。已用内存持续增长 可能意味着存在缓存键没有设置过期时间(TTL)的问题。
  • 键值统计: 展示当前 Redis 实例中存储的键(Key)的总数量,以及每个数据库(db0, db1, …)的键数量和过期键数量。

实战场景:检查登录 Token 缓存

  1. 导航: 进入 系统监控 -> 缓存列表
  2. 观察键值统计: 在“键值统计”部分,您会看到 db0 中存在一定数量的 keys。这些键主要就是若依用来存储用户登录信息(LoginUser)的缓存,键的格式通常是 login_tokens:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  3. 验证过期机制: 登录一个用户,然后观察 keys 数量增加。等待超过 Token 的有效期(默认为 30 分钟)后再次观察,理论上对应的 login_tokens: 键应该会被 Redis 自动清除。

4.4. 数据监控:你的 SQL 性能优化利器

是什么?
数据监控实际上是阿里巴巴著名的数据库连接池项目 Druid 自带的监控后台。若依巧妙地将其内嵌到了自己的管理界面中。

核心功能:

  • 数据源信息: 显示数据库连接池的详细状态,如活动连接数、空闲连接数、最大连接数等。如果 活动连接数持续接近最大值,说明数据库连接可能成为瓶颈。
  • SQL 监控: 这是最有价值的功能。它会记录并分析应用执行过的每一条 SQL 语句的性能。
    • 执行最长: 按执行耗时排序,可以快速定位到系统中性能最差的“慢 SQL”。
    • 执行次数最多: 按执行次数排序,有助于找到最频繁被调用的 SQL,这些 SQL 即使单次执行不慢,高频调用也可能造成性能问题。
    • 错误次数最多: 快速定位到执行出错的 SQL。
  • URL 监控 / Spring 监控: 从不同维度分析应用的接口性能。

实战场景:定位慢查询

  1. 导航与登录: 进入 系统监控 -> 数据监控。首次访问需要登录,默认账号密码是 ruoyi / 123456(可在 application.yml 中修改)。
  2. 访问 SQL 监控: 在 Druid 后台,点击左侧的 SQL 监控
  3. 执行操作: 回到若依主界面,多次访问“课程管理”页面并执行几次查询操作。
  4. 分析 SQL: 刷新 Druid 的“SQL 监控”页面。您会在列表中看到类似 SELECT count(0) FROM tb_course WHERE ...SELECT ... FROM tb_course WHERE ... LIMIT ... 的 SQL 语句。点击任意一条 SQL,可以查看其详细的执行计划、耗时分布等信息。如果某条 SQL 执行时间过长,这里就是您开始进行数据库优化的起点。

第五章. 架构解构:浅析若依的“五脏六腑”

摘要: 在前四章中,我们已经熟练掌握了若依的快速搭建、核心价值(代码生成)、核心概念(权限/字典/组织架构)以及系统监控。至此,我们已经具备了高效 “使用”和“管理” 若依的能力。本章,我们将开启一段全新的旅程,引导您从“使用者”向“开发者”迈出至关重要的一步。我们将不再聚焦于后台的界面操作,而是拿起“放大镜”和“手术刀”,深入前后端项目的源码,彻底解构支撑这一切高效运转的内部构造与设计哲学。


5.1. 后端架构:多模块的职责与协同

5.1.1.浅析设计哲学

我们初次用 IDE 打开若依的后端项目时,普遍会产生一个困惑:为什么不把所有代码都放在一个项目里?ruoyi-adminruoyi-commonruoyi-system 等如此多的模块,它们各自的作用是什么?

这个问题的答案,根植于现代软件工程的核心设计原则——“分层架构”。对于一个企业级的、功能复杂的应用而言,将所有代码堆砌在一起会迅速导致项目变得难以维护、难以理解、难以扩展。若依的多模块(Multi-Module)架构正是为了解决这一难题而设计的。

我们将这种设计哲学带来的核心优势归纳为以下四点:

  • 高内聚、低耦合
    “内聚”指模块内部的各个元素(类、方法)联系的紧密程度,“耦合”则指模块与模块之间的依赖程度。一个优秀的架构追求高内聚、低耦合。在若依中,ruoyi-system 模块只包含用户、角色、菜单等核心系统功能的代码(高内聚),而它与 ruoyi-quartz(定时任务)模块之间没有直接的依赖关系(低耦合)。这使得我们在修改系统管理功能时,完全不必担心会影响到定时任务的逻辑。

  • 职责分离
    每个模块都有其清晰、单一的职责。ruoyi-framework 只负责框架层面的配置与支撑(如安全、数据源),ruoyi-common 只提供全局通用的工具与实体。这种清晰的边界划分,使得我们遇到问题时,能够快速定位到应该检查哪个模块,极大地提升了开发和排错效率。

  • 可复用性
    模块化设计天然地促进了代码复用。例如,ruoyi-common 模块作为一个通用的工具包,它不依赖任何具体的业务。我们可以轻易地将其打包,并应用到公司内部的任何其他 Java 项目中,而无需进行任何修改。

  • 按需裁剪
    并非所有项目都需要若依的全部功能。例如,如果我们的项目不需要代码生成或定时任务,我们可以直接在主模块 ruoyi-adminpom.xml 中移除对 ruoyi-generatorruoyi-quartz 的依赖。这样,这两个模块的功能就不会被打包到最终的可执行文件中,使得我们的应用更加轻量。


5.1.2. 核心模块地图:职责与定位

为了让大家对若依的后端结构形成一个清晰的“模块地图”,我们用表格的形式,为每个核心模块定义其精确的“角色卡”。当您看到某个模块名时,大脑中应能立刻浮现出它的核心职责。

image-20240515202429752

模块名核心职责举例说明
ruoyi-admin启动入口 & 业务暴露层包含 RuoYiApplication 启动类;存放所有业务 Controller(如我们生成的 CourseController)。它是整个应用的“总开关”和“接待大厅”。
ruoyi-system核心业务逻辑层存放系统内置核心功能(用户、角色、菜单、部门等)的 Service 和 Mapper 接口及其实现。它是若依自带功能的“业务处理中心”。
ruoyi-framework框架核心配置与支撑包含了 Spring Security 的安全配置、MyBatis 配置、全局拦截器、多数据源配置、权限服务 (ss) 等。它是整个应用的“骨架”和“神经中枢”。
ruoyi-common通用工具与核心域存放全局共享的工具类 (StringUtils)、全局常量、自定义注解 (@Log)、核心实体基类 (BaseController, AjaxResult) 等。它是所有模块的“公共工具箱”。
ruoyi-generator代码生成器模块 (可移除)包含了代码生成器的独立引擎和相关逻辑。它是一个辅助开发的“工具”,与核心业务无关。
ruoyi-quartz定时任务模块 (可移除)封装了 Quartz 调度框架,用于执行定时任务。这也是一个相对独立的功能模块。

5.1.3. 依赖关系解密:Maven 如何协同工作

我们已经理解了每个模块的独立职责,现在需要探究它们是如何通过 Maven 这一构建工具,被有机地组织在一起协同工作的。

首先,我们通过中这一章依赖关系图,直观地感受一下这种组织结构。

image-20240515202002135

这张图清晰地展示了一个“思维导图”式的分层结构。ruoyi-admin 在最顶层,依赖所有其他模块;ruoyi-common 在最底层,被所有模块依赖。这一切都由 pom.xml 文件精确定义。

我们将这个过程分为两步来理解:版本管理依赖声明

  • 第一步:父工程的“版本管理中心” - <dependencyManagement>
    在多模块项目中,最大的挑战之一是保证所有模块使用的第三方依赖版本是一致的。如果 ruoyi-system 使用了 fastjson-2.0.50,而 ruoyi-framework 却使用了 fastjson-2.0.58,就可能引发难以预料的兼容性问题。为了解决这个问题,若依在最顶层的父工程 pom.xml 中使用了 <dependencyManagement> 标签。它的作用就像一个“版本管理中心”,它只 声明 依赖的版本,但 不实际引入

    文件路径: [项目根目录]/pom.xml

    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
    <!-- 依赖声明 -->
    <dependencyManagement>
    <dependencies>

    <!-- SpringBoot的依赖配置-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-dependencies</artifactId>
    <version>3.5.4</version>
    <type>pom</type>
    <scope>import</scope>
    </dependency>

    <!-- 阿里数据库连接池 -->
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-3-starter</artifactId>
    <version>${druid.version}</version>
    </dependency>

    <!-- ... 其他第三方依赖的版本声明 ... -->

    <!-- 核心模块-->
    <dependency>
    <groupId>com.ruoyi</groupId>
    <artifactId>ruoyi-framework</artifactId>
    <version>${ruoyi.version}</version>
    </dependency>

    <!-- 系统模块-->
    <dependency>
    <groupId>com.ruoyi</groupId>
    <artifactId>ruoyi-system</artifactId>
    <version>${ruoyi.version}</version>
    </dependency>

    <!-- 通用工具-->
    <dependency>
    <groupId>com.ruoyi</groupId>
    <artifactId>ruoyi-common</artifactId>
    <version>${ruoyi.version}</version>
    </dependency>

    </dependencies>
    </dependencyManagement>

在这里,所有依赖(包括若依自身的模块)的版本都被统一管理。子模块在引入这些依赖时,将无需再指定版本号,Maven 会自动从父工程的“版本仲裁中心”获取。

  • 第二步:子模块的“依赖声明” - <dependencies>
    当父工程定义好版本后,子模块就可以按需、清晰地声明自己需要哪些模块和工具了。我们以 ruoyi-admin 为例,它是整个应用的“组装车间”。

    文件路径: ruoyi-admin/pom.xml

    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
    <dependencies>

    <!-- spring-boot-devtools -->
    <dependency>
    <groupId> org.springframework.boot </groupId>
    <artifactId> spring-boot-devtools </artifactId>
    <optional> true </optional> <!-- 表示依赖不会传递 -->
    </dependency>

    <!-- spring-doc -->
    <dependency>
    <groupId> org.springdoc </groupId>
    <artifactId> springdoc-openapi-starter-webmvc-ui </artifactId>
    </dependency>

    <!-- Mysql驱动包 -->
    <dependency>
    <groupId> com.mysql </groupId>
    <artifactId> mysql-connector-j </artifactId>
    </dependency>

    <!-- 核心模块-->
    <dependency>
    <groupId> com.ruoyi </groupId>
    <artifactId> ruoyi-framework </artifactId>
    </dependency>

    <!-- 定时任务-->
    <dependency>
    <groupId> com.ruoyi </groupId>
    <artifactId> ruoyi-quartz </artifactId>
    </dependency>

    <!-- 代码生成-->
    <dependency>
    <groupId> com.ruoyi </groupId>
    <artifactId> ruoyi-generator </artifactId>
    </dependency>

    </dependencies>

    我们注意到,ruoyi-admin 在引入 ruoyi-frameworkruoyi-quartz 等模块时,只提供了 groupIdartifactId,完全没有 <version> 标签。这正是 <dependencyManagement> 发挥作用的结果。这种做法极大地简化了子模块的 pom.xml,并从根本上保证了整个项目的版本一致性。


5.1.4. ruoyi-common 深度剖析:通用能力的基石

我们首先将目光投向整个项目架构的最底层——ruoyi-common 模块。如果说 ruoyi-admin 是应用的“大脑”,那么 ruoyi-common 就是为整个身体提供基础营养和工具的“循环系统”。它被所有其他模块依赖,其核心定位是:提供独立于任何具体业务的、全局通用的工具类、核心实体定义与常量

我们不必要深入分析这些提供好的工具类,而是深入其最核心的 core 包,通过“渐进式构建”的方式,来理解若依是如何通过它来解决开发中的普遍痛点的。

  • core 包:核心抽象与封装
    此包是 ruoyi-common 的心脏。我们首先聚焦于 controller.BaseController,因为它是我们二次开发中接触最频繁、感受最直接的类。

    痛点场景: 设想一下,如果没有 BaseController,我们为“课程管理”编写一个分页查询方法,可能需要这样做:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 一个没有继承 BaseController 的、重复繁琐的 Controller 示例
    @RestController
    public class CourseController {
    @GetMapping("/list")
    public Map<String, Object> list(HttpServletRequest request) {
    // 1. 手动从 request 获取分页参数
    int pageNum = Integer.parseInt(request.getParameter("pageNum"));
    int pageSize = Integer.parseInt(request.getParameter("pageSize"));

    // 2. 手动启动分页
    PageHelper.startPage(pageNum, pageSize);
    List<Course> list = courseService.selectCourseList();
    PageInfo<Course> pageInfo = new PageInfo<>(list);

    // 3. 手动封装成前端需要的格式
    Map<String, Object> result = new HashMap<>();
    result.put("code", 200);
    result.put("msg", "查询成功");
    result.put("rows", list);
    result.put("total", pageInfo.getTotal());
    return result;
    }
    }

    我们能看到大量的“样板代码”:手动解析参数、手动封装返回结果。每个需要分页的查询方法都这么写,无疑是一场灾难。若依的 BaseController 正是为了根除这些痛点而设计的。现在,我们来逐步拆解它的核心能力。

    能力一:无感分页 startPage()
    首先,BaseController 承诺开发者无需再关心分页参数的获取。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // BaseController.java 的部分代码
    /**
    * 设置请求分页数据
    */
    protected void startPage()
    {
    // 内部调用了 PageUtils 工具类,封装了从请求中获取参数的细节
    PageUtils.startPage();
    }
    • 逐行讲解: 这一行代码的背后,PageUtils.startPage() 会自动从 HttpServletRequest 中查找 pageNumpageSize 等参数,并调用 MyBatis PageHelper 插件的 PageHelper.startPage() 方法。
    • 核心原理: PageHelper 的核心是利用 ThreadLocal 变量。当 startPage() 被调用时,分页参数会被存入当前线程的 ThreadLocal 中。随后,当这个线程执行 MyBatis 查询时,PageHelper 的拦截器会从 ThreadLocal 中取出分页参数,并自动地、动态地改写即将执行的 SQL 语句,为其加上 LIMIT 子句。这正是“无感分页”的魔力所在。

    能力二:标准表格数据封装 getDataTable()
    解决了分页,BaseController 接着解决了响应封装的痛点。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // BaseController.java 的部分代码
    /**
    * 响应请求分页数据
    */
    @SuppressWarnings({ "rawtypes", "unchecked" })
    protected TableDataInfo getDataTable(List<?> list)
    {
    TableDataInfo rspData = new TableDataInfo();
    rspData.setCode(HttpStatus.SUCCESS);
    rspData.setMsg("查询成功");
    rspData.setRows(list);
    // 关键之处:从分页结果中获取总记录数
    rspData.setTotal(new PageInfo(list).getTotal());
    return rspData;
    }
    • 逐行讲解: 这个方法接收一个 List(这正是 PageHelper 分页查询后返回的、只包含当前页数据的列表)。它创建了一个 TableDataInfo 对象,并设置了标准的状态码和消息。最关键的一行是 rspData.setTotal(new PageInfo(list).getTotal()),PageHelper 在执行分页查询后,会将总记录数也存放在一个 Page 对象中,new PageInfo(list) 正是用于从中提取出这个总记录数。
  • 实战价值: 通过 startPage() + getDataTable() 的组合,我们将之前那个繁琐的 Controller 方法,简化为了优雅的三行代码,这在 5.4 节 会有更详细的实战。

    能力三:统一操作结果响应 toAjax()
    对于增、删、改操作,我们通常关心的是“操作是否成功”。BaseController 为此提供了极致的便利。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // BaseController.java 的部分代码
    /**
    * 响应返回结果
    *
    * @param rows 影响行数
    * @return 操作结果
    */
    protected AjaxResult toAjax(int rows)
    {
    return rows > 0 ? AjaxResult.success() : AjaxResult.error();
    }
    • 逐行讲解: Service 层的增删改方法通常返回一个 int 型的受影响行数。在 Controller 中,我们只需将这个 int 值传入 toAjax() 方法。这个方法通过一个简单的三元表达式,就将一个纯粹的技术性返回值(受影响行数),转换成了一个对前后端都有明确业务含义的 AjaxResult 对象(成功或失败)。这种封装,让我们的 Controller 代码不仅简洁,而且表意更加清晰。

通过对 BaseController 核心能力的“渐进式”剖析,我们已经深入理解了 ruoyi-common 的设计精髓。除了 BaseControllercore.domain 包下的 AjaxResult(统一响应契约)、BaseEntity(通用字段基类),以及 exception(统一异常处理)、utils(静态工具库)和 annotation(AOP 注解)等包,共同构成了这个强大而可靠的项目基石。


5.1.5. ruoyi-framework 剖析:应用的骨架与安全中枢

如果说 ruoyi-common 是项目的“循环系统”,那么 ruoyi-framework 就是支撑整个应用的“底层骨架”与“安全系统”。它不包含任何具体业务逻辑,其核心职责是整合并配置 Spring Boot、Spring Security 等核心框架,为上层业务(如 ruoyi-system)提供一个稳定、安全、可依赖的运行环境。

我们将聚焦于其最重要的两个部分:config(框架配置)与 security(安全实现),来理解这个“骨架”是如何搭建起来的。

  • config 包:框架级配置中心
    此包是若依对所有第三方框架进行集中配置的地方。我们重点剖析其心脏——SecurityConfig.java

    痛点场景: 在一个没有框架封装的 Spring Security 项目中,我们需要编写大量冗长的 XML 或 Java 配置来定义每一个 URL 的访问权限、指定登录页面、配置 session 策略、处理 CSRF 防护等等。这个过程极其繁琐且容易出错。SecurityConfig.java 的目的,就是将这些复杂的配置,用一种清晰、结构化的方式组织起来。

    我们来“渐进式”地解读 SecurityConfig.java 的核心配置流程。

    第一步:开启方法级安全注解

    1
    2
    3
    4
    5
    6
    7
    // SecurityConfig.java 的起始部分
    @EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
    @Configuration
    public class SecurityConfig
    {
    // ...
    }
  • 逐行讲解: @EnableMethodSecurity(prePostEnabled = true) 是整个若依权限体系的“总开关”。正是这个注解,激活了 Spring Security 对 @PreAuthorize 注解(我们在第三章使用过的)的解析能力。一旦开启,Spring AOP 就会为所有标记了 @PreAuthorize 的方法创建一个代理,在方法执行前,检查当前用户的权限是否满足注解中定义的表达式(如 @ss.hasPermi('course:course:list'))。

    第二步:构建核心过滤器链 filterChain()
    这是安全配置的核心,它像一个“安检流水线”,定义了所有 HTTP 请求需要经过哪些安全检查站。

    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
    a
    // SecurityConfig.java 的核心方法
    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception
    {
    return httpSecurity
    // CSRF 禁用,因为我们是前后端分离,通过 token 认证,不依赖 session 和 cookie
    .csrf(csrf -> csrf.disable())
    // ... 其他基础配置 ...

    // 基于 token,所以不需要 session,设置为无状态
    .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

    // 配置 URL 的访问权限
    .authorizeHttpRequests((requests) -> {
    // 允许匿名访问的路径,通常由配置文件读取
    permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll());
    // 硬编码的匿名访问路径,如登录、注册、获取验证码
    requests.requestMatchers("/login", "/register", "/captchaImage").permitAll()
    // 除上面外的所有请求,全部需要经过身份认证
    .anyRequest().authenticated();
    })

    // ... 添加自定义过滤器 ...
    .build();
    }
    • 逐行讲解:
      • .csrf(csrf -> csrf.disable()): 在前后端分离的架构中,认证信息通过 Authorization 头中的 Token 传递,不依赖于浏览器的 Cookie-Session 机制,因此传统的 CSRF(跨站请求伪造)攻击方式不再适用,可以安全地禁用它。
      • .sessionManagement(...STATELESS): 明确告诉 Spring Security,我们的应用是“无状态”的,服务端不会创建或维护任何 HttpSession,每次请求都将独立认证。这是构建可伸缩、高性能服务的关键。
        • .authorizeHttpRequests(...): 这是 URL 权限配置的核心。若依采用“白名单”策略:先通过 permitAll() 方法,明确定义哪些路径(如登录页、静态资源)无需认证即可访问;然后通过 .anyRequest().authenticated(),规定 除此之外的所有其他请求 都必须经过身份认证。
  • security 包:安全机制的具体实现
    SecurityConfig 搭建了骨架,而 security 包则为这个骨架填充了“血肉”。我们重点关注 filter.JwtAuthenticationTokenFilter,它是若依 Token 认证机制的“心脏”。

    痛点场景: SecurityConfig 只规定了“哪些请求需要认证”,但并没有说明“如何进行认证”。JwtAuthenticationTokenFilter 的职责,就是在每个需要认证的请求到达时,执行具体的 Token 校验和用户身份构建工作。

    doFilterInternal() 方法的执行流程:

    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
    // JwtAuthenticationTokenFilter.java 的核心方法
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
    throws ServletException, IOException
    {
    // 1. 从请求中获取 LoginUser 对象(TokenService 内部会解析请求头中的 Token)
    LoginUser loginUser = tokenService.getLoginUser(request);

    // 2. 判断用户是否存在,且当前上下文中没有认证信息
    if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
    {
    // 3. 验证 Token 有效性(例如,检查是否过期)
    tokenService.verifyToken(loginUser);

    // 4. 构建一个代表当前用户的认证成功的令牌(AuthenticationToken)
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
    authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

    // 5. 将这个令牌存入 SecurityContextHolder,完成认证
    // 后续的 Spring Security 组件(如 AOP 注解)就可以从这里获取到当前用户信息了
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }

    // 6. 放行请求,让它继续走向 Controller
    chain.doFilter(request, response);
    }
    • 核心原理: 这个过滤器继承自 OncePerRequestFilter,确保在一次请求中只执行一次。它的核心逻辑是:
      1. 调用 TokenService 从请求的 Authorization 头中解析出 JWT。
      2. 如果解析成功并获取到 LoginUser(代表用户已登录且 Token 有效),并且当前 SecurityContextHolder 中是空的(说明这是本次请求的第一次认证)。
      3. 它会创建一个 UsernamePasswordAuthenticationToken 对象,这个对象是 Spring Security 内部用来表示“一个已认证用户”的标准凭证。
      4. 最后,它将这个凭证放入 SecurityContextHolder 这个线程绑定的“全局容器”中。一旦 SecurityContextHolder 中有了认证信息,后续的所有安全检查(比如 @PreAuthorize 注解)就都可以从中获取到当前用户的身份和权限,从而做出正确的决策。

通过对 ruoyi-framework 的剖析,我们理解了若依是如何利用 Spring Security 构建起一个强大的、基于 Token 的、无状态的安全体系的。这套体系既保证了应用的安全性,又为二次开发提供了清晰的扩展点。


5.1.6. ruoyi-system 剖析:内置核心业务的“示范样本”

在理解了底层的工具与框架模块后,我们现在聚焦于真正承载 业务逻辑ruoyi-system 模块。我们必须明确其定位:它并 不是 一个框架或工具模块,而是若依 内置核心业务 的实现模块。它本身就是一个标准的、自包含的业务模块范例,是我们在进行二次开发时最好的“活教材”和“最佳实践参考”。

ruoyi-system 的代码组织结构

痛点场景: 当我们准备进行二次开发,想要添加一个新的业务模块(例如“订单管理”)时,最常问的问题是:“我的代码应该如何组织?domainservicemapper 应该怎么写?”

ruoyi-system 模块通过其清晰的目录结构,给出了这个问题的标准答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
. 📂 ruoyi-system
└── 📂 src/
├── 📂 main/
│ ├── 📂 java/
│ │ └── .../system/
│ │ ├── 📂 domain/ // 业务实体定义层
│ │ ├── 📂 mapper/ // 数据访问接口层
│ │ └── 📂 service/ // 业务逻辑核心层
│ │ └── 📂 impl/ // 业务逻辑实现
│ └── 📂 resources/
│ └── 📂 mapper/
│ └── 📂 system/ // MyBatis XML 文件
...

这是一种经典且高效的三层架构实现:

  1. domain 层:业务实体的定义

    • 职责: 此目录下的 Java 类(如 SysUser.java, SysRole.java)是业务领域模型的实体映射。它们是纯粹的数据载体(POJO),负责定义业务对象的属性,与数据库表结构一一对应。
  2. mapper 层:数据访问的接口与实现

    • 职责: 此层是与数据库打交道的 唯一 入口,实现了数据访问与上层业务逻辑的彻底隔离。
      • mapper/ 目录下的 Java 接口(如 SysUserMapper.java)定义了所有数据库操作的方法签名。
      • resources/mapper/system/ 目录下的 XML 文件(如 SysUserMapper.xml)则通过 MyBatis 的 SQL 标签,编写了这些接口方法对应的具体 SQL 语句。
  3. service 层:业务逻辑的核心

    • 职责: 如果说 mapper 层执行的是“原子操作”(单次数据库交互),那么 service 层就是负责编排这些原子操作,来完成一个完整、复杂的业务功能的“指挥官”。
      • service/ 目录下的接口(如 ISysUserService.java)定义了业务层需要对外提供的能力契约。
      • service/impl/ 目录下的实现类(如 SysUserServiceImpl.java)则是业务规则、事务管理、缓存处理、权限校验等复杂逻辑的真正所在地。

为了具体地理解 Service 层是如何“指挥”和“编排”的,我们深入到 SysUserServiceImpl.java 中,通过其 insertUser 方法,来观察一个“新增用户”功能的完整实现过程。

痛点场景: 一个看似简单的“新增用户”功能,背后其实隐藏着多个业务步骤和规则:不仅要插入用户基本信息,还要建立用户与岗位、用户与角色的关联关系,并且这一切操作必须保证 事务性——要么全部成功,要么全部失败。

SysUserServiceImpl.java 中的 insertUser 方法完美地展示了如何处理这种复杂场景。

第一步:事务管理与方法签名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SysUserServiceImpl.java 的部分代码
@Service
public class SysUserServiceImpl implements ISysUserService
{
// ... 注入多个 Mapper ...
@Autowired
private SysUserMapper userMapper;
@Autowired
private SysUserRoleMapper userRoleMapper;
@Autowired
private SysUserPostMapper userPostMapper;

/**
* 新增保存用户信息
*
* @param user 用户信息
* @return 结果
*/
@Override
@Transactional // <- 核心注解:声明此方法为一个事务
public int insertUser(SysUser user)
{
// ... 方法体 ...
}
  • 逐行讲解: @Transactional 注解是 Spring 提供的声明式事务管理。一旦标记在此方法上,Spring AOP 会为它创建一个代理。在方法开始执行前,代理会自动开启一个数据库事务;如果方法成功执行完毕,事务会自动提交;如果方法在执行过程中抛出任何运行时异常,事务会自动回滚。这确保了“新增用户”操作的原子性。

第二步:编排多个 Mapper 完成核心业务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// insertUser 方法的核心逻辑
@Override
@Transactional
public int insertUser(SysUser user)
{
// 第 1 步:调用 userMapper,插入用户基本信息到 sys_user 表
int rows = userMapper.insertUser(user);

// 第 2 步:调用内部方法,处理用户与岗位的关联关系
insertUserPost(user);

// 第 3 步:调用内部方法,处理用户与角色的关联关系
insertUserRole(user);

return rows;
}
  • 逐行讲解: 这里清晰地展示了 Service 层的“编排”职责。它没有自己编写任何 SQL,而是像指挥官一样,依次调用三个不同的 Mapper(或封装了 Mapper 调用的内部方法)来协同完成任务:
    1. userMapper.insertUser(user): 完成最基础的用户信息持久化。
    2. insertUserPost(user): 内部会调用 userPostMapper,将用户 ID 和岗位 ID 批量插入到 sys_user_post 关联表中。
    3. insertUserRole(user): 内部会调用 userRoleMapper,将用户 ID 和角色 ID 批量插入到 sys_user_role 关联表中。

这三个步骤被 @Transactional 注解紧密地包裹在一个事务中,共同构成了一个不可分割的业务单元。

通过对 ruoyi-system 模块的结构与核心代码的深度剖析,我们不仅理解了若依内置功能的实现方式,更重要的是,我们掌握了一套可以在自己二次开发中直接应用的、标准的、健壮的业务分层与代码组织范式。


5.1.7. ruoyi-admin 剖析:应用的入口与配置中心

我们终于来到了项目架构的最顶层——ruoyi-admin 模块。如果说其他模块是各司其职的“零部件”,那么 ruoyi-admin 就是将所有零部件最终组装起来的“总装车间”和“主控制台”。它扮演着两个至关重要的角色:应用启动入口全局配置中心

1739625552294

  1. 应用启动入口
    这是 ruoyi-admin 最核心的职责。应用的“点火”操作,正是在这个模块中完成的。

    文件路径: ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    package com.ruoyi;

    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;

    /**
    * 启动程序
    *
    * @author ruoyi
    */
    @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
    public class RuoYiApplication
    {
    public static void main(String[] args)
    {
    SpringApplication.run(RuoYiApplication.class, args);
    // ... 打印启动成功 banner ...
    }
    }
  2. Controller 聚合器与业务暴露层
    正如 ruoyi-admin 的目录结构所示,它内部的 web/controller 目录聚合了来自不同模块的 Controller,是所有 HTTP 请求的统一入口。

    • system 包下的 Controller(如 SysUserController)负责暴露 ruoyi-system 模块的业务接口。
    • monitor 包下的 Controller(如 ServerController)负责暴露系统监控相关的接口。
    • 我们自己生成的 course 包下的 Controller,也自然地归属在这里。

ruoyi-admin 的配置中心体系

ruoyi-adminsrc/main/resources 目录,是整个应用的 主配置中心,存放了所有与应用运行相关的配置文件。

  • application.yml:主配置文件
    这是应用的核心配置文件,采用 YAML 格式,层级清晰。它定义了应用的基础行为。我们重点解读几个核心配置:

    在传统 Spring 项目中,我们需要编写大量 XML 来配置端口、上下文路径、Redis 连接等。application.yml 通过“约定优于配置”的思想,极大地简化了这些工作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # 开发环境配置
    server:
    # 服务器的HTTP端口,默认为8080
    port: 8080
    servlet:
    # 应用的访问路径
    context-path: /

    # Spring配置
    spring:
    # redis 配置
    redis:
    # 地址
    host: localhost
    # 端口,默认为6379
    port: 6379

    这种声明式的配置方式直观易懂。Spring Boot 在启动时会自动读取这些配置,并应用到相应的组件中。例如,server.port: 8080 会直接配置内嵌的 Tomcat 服务器监听 8080 端口。

  • application-{profile}.yml:环境分离的艺术
    我们注意到还有一个 application-druid.yml 文件。这是 Spring Boot Profiles(环境配置) 功能的体现。

    application.yml 中有这样一行配置:

    1
    2
    3
    spring:
    profiles:
    active: druid
    • 核心原理: 这一行配置告诉 Spring Boot:“在加载主配置文件 application.yml 之后,请继续加载名为 application-druid.yml 的配置文件”。application-druid.yml 中的配置项会 覆盖 主配置文件中的同名项。
    • 实战价值: 这种机制在多环境部署(如开发 dev、测试 test、生产 prod)时至关重要。我们可以创建 application-dev.yml, application-prod.yml 等文件,在其中定义不同环境下的数据库地址、Redis 地址等。部署时,只需通过启动参数(如 -Dspring.profiles.active=prod)来切换环境,而无需修改任何代码或配置文件本身。

通过对 ruoyi-admin 模块的剖析,我们理解了它作为“总装车间”和“主控制台”的核心地位。它不仅是应用的启动入口,更是所有模块 Controller 的聚合点和所有配置文件的管理中心,将整个多模块项目有机地融为一体。


5.2. 前端架构:项目结构与核心文件导览

5.2.1. 工程化基石:.env 环境配置与 package.json 依赖蓝图

在深入若依前端的源码之前,我们必须先理解其工程化的两大基石:用于管理多环境配置的 .env 文件体系,以及定义项目依赖与脚本的 package.json。它们共同决定了项目的构建行为和运行环境。

.env 环境配置:构建不同环境的“配置文件”

痛点场景: 在软件开发中,开发、测试、生产环境的 API 地址、应用标题等配置通常是不同的。如果将这些配置硬编码在代码中,每次部署都需要手动修改,极其繁琐且容易出错。

若依采用了 Vite 支持的 .env 文件体系来优雅地解决此问题。在项目根目录下,我们可以看到三个核心的 .env 文件:

  • .env.development: 开发环境配置文件
    1
    2
    3
    4
    5
    6
    7
    8
    # 页面标题
    VITE_APP_TITLE = 若依管理系统

    # 开发环境配置
    VITE_APP_ENV = 'development'

    # API 请求基础路径(用于代理)
    VITE_APP_BASE_API = '/dev-api'
  • .env.production: 生产环境配置文件
    1
    2
    3
    4
    5
    6
    7
    8
    # 页面标题
    VITE_APP_TITLE = 若依管理系统

    # 生产环境配置
    VITE_APP_ENV = 'production'

    # API 请求基础路径
    VITE_APP_BASE_API = '/prod-api'
  • .env.staging: 预发布(测试)环境配置文件
    1
    2
    3
    4
    5
    6
    7
    8
    # 页面标题
    VITE_APP_TITLE = 若依管理系统

    # 预发布环境配置
    VITE_APP_ENV = 'staging'

    # API 请求基础路径
    VITE_APP_BASE_API = '/stage-api'

核心工作机制:
这个体系的核心在于 package.json 中的 scripts 命令与 Vite 的构建模式(mode)相结合。

1
2
3
4
5
6
// package.json
"scripts": {
"dev": "vite", // 默认 mode 为 'development'
"build:prod": "vite build --mode production", // 显式指定 mode 为 'production'
"build:stage": "vite build --mode staging", // 显式指定 mode 为 'staging'
},
  • 工作流剖析:
    1. 当我们执行 pnpm dev 时,Vite 会自动加载 .env.development 文件。
    2. 当我们执行 pnpm build:prod 时,--mode production 参数会告诉 Vite 去加载 .env.production 文件。
    3. Vite 会将这些文件中以 VITE_ 开头的变量,注入到一个名为 import.meta.env 的全局环境变量对象中。
    4. 在项目的任何地方(如 request.js 中),我们都可以通过 import.meta.env.VITE_APP_BASE_API 来获取当前环境对应的 API 基础路径。

通过这种方式,若依实现了配置与代码的完全分离,使得一次构建、多环境部署成为可能。

package.json:项目的“依赖蓝图”与“脚本中心”

package.json 文件是前端项目的“心脏”,它详细描述了项目的构成。我们将其核心内容归纳为两部分:依赖蓝图和脚本中心。

  • 依赖蓝图 (dependencies & devDependencies)
    若依 Vue3 版精心选择了一系列高质量的第三方库来构建其功能。我们将核心依赖按其作用进行分类归纳:
分类核心依赖版本作用说明
Vue 全家桶vue3.5.16核心框架
vue-router4.5.1官方路由管理器
pinia3.0.2官方状态管理器(替代 Vuex)
UI 组件库element-plus2.10.7核心 UI 组件库
@element-plus/icons-vue2.3.1Element Plus 图标库
HTTP 通信axios1.9.0强大的 HTTP 客户端,用于与后端交互
功能插件@vueup/vue-quill1.2.0富文本编辑器
echarts5.6.0数据可视化图表库
sortablejs1.15.6拖拽排序库
vue-cropper1.1.1图片裁剪组件
开发工具vite6.3.5(Dev) 项目构建与开发服务器引擎
sass-embedded1.89.1(Dev) CSS 预处理器,用于编写 SCSS
unplugin-auto-import0.18.6(Dev) 自动导入 API 插件,简化编码
vite-plugin-svg-icons2.0.1(Dev) SVG 图标自动化处理插件
  • 脚本中心 (scripts)
    scripts 字段定义了项目的标准工作流命令。

    1
    2
    3
    4
    5
    6
    "scripts": {
    "dev": "vite",
    "build:prod": "vite build --mode production",
    "build:stage": "vite build --mode staging",
    "preview": "vite preview"
    },
    • dev: 启动开发服务器,用于日常开发和调试。
    • build:prod: 执行生产环境打包。此命令会读取 .env.production,并对代码进行压缩、混淆、Tree-shaking 等优化,生成用于线上部署的静态文件。
    • build:stage: 执行预发布环境打包,读取 .env.staging
    • preview: 在本地预览打包后的生产环境产物,用于部署前的最后检查。

通过对 .env 文件体系和 package.json 的深度剖析,我们理解了若依前端项目是如何管理多环境配置,以及如何组织和构建其庞大的依赖体系的。这为我们后续深入源码打下了坚实的基础。


5.2.2. vite.config.js 深度剖析:项目的“构建总管”

在理解了 .envpackage.json 如何定义项目的环境与依赖后,我们现在聚焦于驱动这一切运转的“引擎室”——vite.config.js。此文件是 Vite 的主配置文件,负责项目的开发服务器、打包构建、插件集成等所有核心行为。

如果说 package.json 定义了“用什么工具”,那么 vite.config.js 则详细规定了“如何使用这些工具”。它是 Vite 的主配置文件,负责开发服务器、项目打包、插件集成等所有行为。

文件路径: [项目根目录]/vite.config.js

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
import { defineConfig, loadEnv } from 'vite'
import path from 'path'
import createVitePlugins from './vite/plugins'

// https://vitejs.dev/config/
export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd())
const { VITE_APP_ENV } = env
return {
// ...
plugins: createVitePlugins(env, command === 'build'),
resolve: {
alias: {
// 设置别名
'@': path.resolve(__dirname, './src')
},
},
server: {
port: 80,
host: true,
open: true,
proxy: {
// https://cn.vitejs.dev/config/#server-proxy
'/dev-api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/dev-api/, '')
},
}
},
// ...
}
})

渐进式解读

plugins: Vite 的核心优势之一在于其插件化的生态系统。若依将所有 Vite 插件的配置都收敛到了 vite/plugins 目录中,并通过 createVitePlugins 函数统一引入。例如,vite/plugins/svg-icon.js 插件负责将 src/assets/icons/svg 目录下的所有 SVG 图标文件,自动处理成可在 Vue 组件中直接使用的图标组件。这种插件化机制使得 vite.config.js 保持了高度的整洁。

resolve.alias: 这是提升开发效率的关键配置。'@': path.resolve(__dirname, './src') 这一行定义了一个路径别名,意味着在项目的任何地方,我们都可以使用 @ 来代替冗长的相对路径(如 ../../..),直接指向 src 目录。这使得代码中的模块导入语句更加清晰和易于维护。

server.proxy: 这是解决开发环境跨域问题的核心

首先,我们必须理解其解决的痛点:前端开发服务器运行在 80 端口,而后端 API 在 8080 端口,这构成了浏览器安全策略中的“跨域”,直接访问会被阻止。

若依的解决方案如下:

1
2
3
4
5
'/dev-api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/dev-api/, '')
}

这段配置的含义是:

  • 拦截规则
    Vite 开发服务器会监听所有由前端发起的请求。如果请求的 URL 以 /dev-api 开头,则触发此代理规则。

  • 目标服务器
    Vite 会将这个被拦截的请求转发给 target 指定的地址,即 http://localhost:8080

  • changeOrigin: true
    在转发时,将请求头中的 Host 字段从当前的前端地址(如 localhost:80)修改为目标服务器的地址。这是确保后端能正确处理请求的关键。

  • rewrite
    在将请求真正发送给后端之前,使用 rewrite 函数重写 URL,将 /dev-api 前缀去掉。例如,前端请求 /dev-api/system/user/list,最终到达后端的将是 /system/user/list

通过对这两个核心文件的剖析,我们理解了若依前端项目是如何通过 package.json 管理依赖,并通过 vite.config.js 进行高效、灵活的工程化配置,为快速开发奠定了坚实的基础。


5.2.3. 项目目录结构与核心文件导览

在理解了若依前端的工程化基础设施后,我们现在需要从整体上把握项目的目录组织架构。一个清晰的目录结构,不仅能让开发者快速定位代码位置,更体现了项目的设计思想和分层理念。

痛点场景: 面对一个拥有数百个文件的前端项目,如果没有清晰的目录规范,开发者往往会陷入 “找不到代码在哪”、“不知道该把新功能写在哪” 的困境。若依通过精心设计的目录结构,将不同职责的代码严格分离,实现了高内聚、低耦合的架构目标。

5.2.3.1. 项目目录树:清晰的分层架构

若依 Vue3 版采用了标准的 Vue 3 + Vite 项目结构,在 src/ 目录下按功能模块进行了精细化分层。以下是核心目录结构的可视化展示:

image-20240515202411640

5.2.3.2. 核心目录职责解析

若依的目录设计遵循了 按职责分层、按业务分模块 的原则。我们将各目录的核心职责归纳如下:

目录职责定位核心作用典型文件示例
api/API 接口层统一管理所有后端接口调用,按业务模块分类,与后端 Controller 层一一对应system/user.js 对应后端 UserController
assets/静态资源库存放图片、图标、样式等静态资源,由 Vite 在构建时处理icons/svg/ 存放 89 个 SVG 图标
components/全局组件库存放项目级别的公共组件,可在任何页面中复用Pagination 分页组件被数十个列表页使用
directive/自定义指令封装 Vue 自定义指令,提供权限控制、DOM 操作等功能增强v-hasPermi 实现按钮级权限控制
layout/布局框架定义系统的整体布局结构(顶栏、侧边栏、内容区),所有业务页面都嵌套在此布局中Sidebar 侧边栏根据权限动态渲染菜单
plugins/功能插件层封装常用功能为插件,挂载到 Vue 实例,方便全局调用$modal.msg() 统一的消息提示
router/路由配置管理前端路由,包括静态路由与动态权限路由区分 constantRoutesdynamicRoutes
store/状态管理基于 Pinia 的集中式状态管理,按模块拆分user.js 管理登录用户信息与 Token
utils/工具函数库封装通用的工具函数,提供可复用的业务逻辑request.js 是 Axios 的二次封装,统一处理请求响应
views/页面视图层按业务模块组织的页面组件,是用户直接交互的界面system/user/index.vue 用户管理页面

设计亮点

  • API 层与视图层分离api/views/ 严格分离,即使切换 UI 框架,API 层代码也无需修改。
  • 组件分级管理:全局组件放 components/,页面专属组件放在对应的 views/ 子目录下。
  • 工具函数模块化utils/ 按功能拆分成多个文件,避免单一文件过于庞大。

5.2.3.3. 核心文件用途浅析

除了目录结构,若依还有几个 位于 src/ 根目录的核心文件,它们是整个应用的 “枢纽”,在系统启动和运行过程中扮演着关键角色。

main.js - 应用的 “启动引擎”

文件路径: src/main.js

这是整个 Vue 应用的入口文件,负责创建 Vue 实例并完成初始化工作。其核心职责包括:

  1. 创建 Vue 应用实例

    1
    const app = createApp(App)
  2. 注册全局插件与组件

    1
    2
    3
    app.use(router)        // 注册路由
    app.use(store) // 注册 Pinia 状态管理
    app.use(ElementPlus) // 注册 Element Plus UI 库
  3. 挂载全局方法到 Vue 实例

    1
    2
    app.config.globalProperties.useDict = useDict       // 字典工具
    app.config.globalProperties.download = download // 下载工具
  4. 注册全局组件(高频使用的组件)

    1
    2
    app.component('Pagination', Pagination)   // 分页组件
    app.component('DictTag', DictTag) // 字典标签
  5. 加载权限控制

    1
    import './permission'  // 导入权限控制逻辑,启动路由守卫

通过 main.js 的统一初始化,确保了整个应用在启动时就完成了所有必要的配置和注册工作。


permission.js - 权限控制的 “守门员”

文件路径: src/permission.js

此文件实现了全局的路由守卫(Route Guard),是若依权限体系的核心。它在 每次路由跳转前 都会执行检查,决定用户是否有权访问目标页面。

核心工作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
router.beforeEach((to, from, next) => {
if (getToken()) {
// 已登录
if (useUserStore().roles.length === 0) {
// 首次登录,拉取用户信息和权限
useUserStore().getInfo().then(() => {
// 根据权限动态生成路由
usePermissionStore().generateRoutes().then(accessRoutes => {
// 将可访问的路由动态添加到路由表
accessRoutes.forEach(route => router.addRoute(route))
next({ ...to, replace: true })
})
})
} else {
next() // 已有权限信息,直接放行
}
} else {
// 未登录,重定向到登录页
next(`/login?redirect=${to.fullPath}`)
}
})

解决的核心问题

  • 未登录用户访问受保护页面时,自动跳转登录页
  • 已登录用户首次访问时,动态加载其有权限的路由
  • 实现了基于角色和权限的精细化访问控制

router/index.js - 路由配置中心

文件路径: src/router/index.js

此文件是前端路由的定义中心,采用了 静态路由 + 动态路由 的混合模式。

两类路由

  1. constantRoutes(静态路由):无需权限即可访问的公共路由

    1
    2
    3
    4
    5
    export const constantRoutes = [
    { path: '/login', component: () => import('@/views/login') },
    { path: '/404', component: () => import('@/views/error/404') },
    { path: '/index', component: () => import('@/views/index') }
    ]
  2. dynamicRoutes(动态路由):需要根据用户权限动态加载的路由

    1
    2
    3
    4
    5
    6
    7
    8
    export const dynamicRoutes = [
    {
    path: '/system/user-auth',
    permissions: ['system:user:edit'], // 需要特定权限
    component: Layout,
    children: [...]
    }
    ]

核心特性

  • 使用 createWebHistory() 启用 HTML5 History 模式(无 # 号)
  • 路由配置中的 meta 字段携带了页面标题、图标、权限等元数据
  • 通过 hidden: true 控制路由是否在侧边栏菜单中显示

utils/request.js - HTTP 通信的 “总管”

文件路径: src/utils/request.js

这是若依对 Axios 进行的二次封装,统一管理所有 HTTP 请求和响应的处理逻辑。

核心功能

  1. 请求拦截器(Request Interceptor)

    • 自动在请求头中添加 Authorization: Bearer [token],实现身份认证
    • 防止重复提交(通过缓存机制判断短时间内的重复请求)
  2. 响应拦截器(Response Interceptor)

    • 统一处理后端返回的状态码(如 401 未授权、500 服务器错误)
    • 根据不同的错误码,自动弹出相应的提示信息
    • Token 过期时自动弹窗提示用户重新登录
  3. 全局配置

    1
    2
    3
    4
    const service = axios.create({
    baseURL: import.meta.env.VITE_APP_BASE_API, // 从环境变量读取 API 基础路径
    timeout: 10000 // 请求超时时间
    })

设计优势:所有页面的 API 调用都通过这个封装的 request 实例,确保了请求处理逻辑的一致性,避免了在每个 API 文件中重复编写认证、错误处理等代码。


store/index.js - 状态管理入口

文件路径: src/store/index.js

若依 Vue3 版使用了 Pinia(Vue 3 官方推荐的状态管理库)替代 Vuex。此文件仅包含 Pinia 实例的创建:

1
2
const store = createPinia()
export default store

真正的状态管理逻辑被拆分到了 store/modules/ 目录下的各个模块中:

  • user.js:管理用户登录状态、Token、角色权限等
  • permission.js:管理动态路由和菜单
  • settings.js:管理系统设置(如侧边栏主题、是否显示标签页等)
  • tagsView.js:管理多标签页的打开/关闭状态

这种模块化的拆分方式,使得状态管理的职责更加清晰,易于维护。


App.vue - 根组件

文件路径: src/App.vue

这是 Vue 应用的根组件,结构极其简洁:

1
2
3
<template>
<router-view />
</template>

它的职责仅仅是渲染路由匹配到的组件。真正的布局结构(侧边栏、顶栏等)由 layout/index.vue 负责。

<script setup> 中,它完成了主题样式的初始化:

1
2
3
onMounted(() => {
handleThemeStyle(useSettingsStore().theme) // 根据用户设置应用主题
})

settings.js - 系统全局配置

文件路径: src/settings.js

此文件定义了系统的默认配置项,如:

1
2
3
4
5
6
7
8
export default {
title: import.meta.env.VITE_APP_TITLE, // 系统标题
sideTheme: 'theme-dark', // 侧边栏主题(深色/浅色)
tagsView: true, // 是否显示标签页
fixedHeader: false, // 是否固定顶部导航栏
sidebarLogo: true, // 是否显示侧边栏 Logo
dynamicTitle: false // 是否启用动态标题
}

这些配置会被 store/modules/settings.js 读取并存入状态管理,用户在 “系统设置” 页面修改这些选项时,实际上是在更新 Store 中的状态。


5.3. 数据库设计:表结构与数据模型深度解析

在深入理解了若依的前端架构后,我们现在将目光转向系统的 “地基”——数据库表设计。一个优秀的表结构设计,不仅决定了系统的数据存储效率,更直接影响着业务逻辑的实现复杂度和系统的可扩展性。

痛点场景: 在企业级应用开发中,权限管理、日志记录、定时任务等功能几乎是标配需求。如果没有成熟的数据库表设计方案,开发者往往会陷入 “表结构设计不合理导致查询性能低下”、“权限控制逻辑复杂度爆炸”、“表关系混乱导致数据一致性问题” 等困境。

若依通过精心设计的 19 张核心业务表 + 11 张 Quartz 调度表,构建了一套完整、规范、可扩展的数据模型体系。我们将按功能模块对这些表进行分类剖析。

ruoyi-vue

5.3.1. 核心权限管理表群:RBAC 模型的五表联动

若依采用了业界成熟的 RBAC(基于角色的访问控制) 模型,通过 5 张核心表实现了 “用户-角色-权限” 的灵活映射关系。

5.3.1.1. RBAC 模型概述

RBAC 核心思想:不直接给用户分配权限,而是先将用户归入不同的角色,再给角色分配权限。这种间接授权的方式,极大地降低了权限管理的复杂度。

若依的 RBAC 实现包含以下核心表

表名作用表类型核心字段
sys_user用户信息表实体表user_id, user_name, password, dept_id, status
sys_role角色信息表实体表role_id, role_name, role_key, data_scope
sys_menu菜单权限表实体表menu_id, menu_name, perms, menu_type
sys_user_role用户角色关联表关联表user_id, role_id
sys_role_menu角色菜单关联表关联表role_id, menu_id

表关系图示

1
2
3
4
5
6
7
8
9
sys_user (用户)
|
| N:1 (通过 sys_user_role)

sys_role (角色)
|
| 1:N (通过 sys_role_menu)

sys_menu (菜单权限)

5.3.1.2. 用户信息表 (sys_user)

表定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
create table sys_user (
user_id bigint(20) not null auto_increment comment '用户ID',
dept_id bigint(20) default null comment '部门ID',
user_name varchar(30) not null comment '用户账号',
nick_name varchar(30) not null comment '用户昵称',
email varchar(50) default '' comment '用户邮箱',
phonenumber varchar(11) default '' comment '手机号码',
sex char(1) default '0' comment '用户性别(0男 1女 2未知)',
avatar varchar(100) default '' comment '头像地址',
password varchar(100) default '' comment '密码',
status char(1) default '0' comment '账号状态(0正常 1停用)',
del_flag char(1) default '0' comment '删除标志(0存在 2删除)',
login_ip varchar(128) default '' comment '最后登录IP',
login_date datetime comment '最后登录时间',
pwd_update_date datetime comment '密码最后更新时间',
create_time datetime comment '创建时间',
update_time datetime comment '更新时间',
remark varchar(500) default null comment '备注',
primary key (user_id)
) comment = '用户信息表';

核心字段解析

字段类型作用设计亮点
user_idbigint(20)用户唯一标识使用 bigint 支持海量用户,自增主键
dept_idbigint(20)所属部门 ID关联 sys_dept 表,支持组织架构管理
passwordvarchar(100)加密后的密码存储 BCrypt 加密后的密文,长度 100 足够
statuschar(1)账号状态0 正常 1 停用,支持账号禁用而不删除
del_flagchar(1)删除标志逻辑删除标记,0 存在 2 删除,保留历史数据
pwd_update_datedatetime密码更新时间用于实现密码过期策略,提升安全性
login_ip / login_datevarchar / datetime登录信息记录最后登录位置,方便安全审计

设计亮点

  1. 逻辑删除机制:使用 del_flag 实现软删除,保留用户历史数据用于审计。
  2. 密码安全策略pwd_update_date 字段支持密码过期提醒,强化安全性。
  3. 部门关联:通过 dept_id 外键,将用户与组织架构绑定,支持数据权限控制。

5.3.1.3. 角色信息表 (sys_role)

表定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
create table sys_role (
role_id bigint(20) not null auto_increment comment '角色ID',
role_name varchar(30) not null comment '角色名称',
role_key varchar(100) not null comment '角色权限字符串',
role_sort int(4) not null comment '显示顺序',
data_scope char(1) default '1' comment '数据范围(1全部 2自定义 3本部门 4本部门及以下)',
menu_check_strictly tinyint(1) default 1 comment '菜单树选择项是否关联显示',
dept_check_strictly tinyint(1) default 1 comment '部门树选择项是否关联显示',
status char(1) not null comment '角色状态(0正常 1停用)',
del_flag char(1) default '0' comment '删除标志(0存在 2删除)',
create_time datetime comment '创建时间',
remark varchar(500) default null comment '备注',
primary key (role_id)
) comment = '角色信息表';

核心字段解析

字段类型作用设计亮点
role_keyvarchar(100)角色权限标识admincommon,用于代码中的角色判断
data_scopechar(1)数据权限范围核心字段,控制用户能看到的数据范围
menu_check_strictlytinyint(1)菜单树关联显示控制父子菜单的联动选择行为

数据权限范围详解data_scope):

含义应用场景
1全部数据权限超级管理员,可查看所有数据
2自定义数据权限通过 sys_role_dept 表指定可访问的部门
3本部门数据权限只能查看自己所在部门的数据
4本部门及以下数据权限可查看本部门及其所有子部门的数据
5仅本人数据权限只能查看自己创建的数据

设计亮点
data_scope 字段是若依权限体系的 核心创新点,它将数据权限控制从代码逻辑中抽离出来,通过配置即可实现灵活的数据隔离。例如,在查询用户列表时,SQL 会根据当前用户的角色 data_scope 自动拼接不同的 WHERE 条件。

5.3.1.4. 菜单权限表 (sys_menu)

表定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
create table sys_menu (
menu_id bigint(20) not null auto_increment comment '菜单ID',
menu_name varchar(50) not null comment '菜单名称',
parent_id bigint(20) default 0 comment '父菜单ID',
order_num int(4) default 0 comment '显示顺序',
path varchar(200) default '' comment '路由地址',
component varchar(255) default null comment '组件路径',
query varchar(255) default null comment '路由参数',
is_frame int(1) default 1 comment '是否为外链(0是 1否)',
is_cache int(1) default 0 comment '是否缓存(0缓存 1不缓存)',
menu_type char(1) default '' comment '菜单类型(M目录 C菜单 F按钮)',
visible char(1) default '0' comment '显示状态(0显示 1隐藏)',
status char(1) default '0' comment '菜单状态(0正常 1停用)',
perms varchar(100) default null comment '权限标识',
icon varchar(100) default '#' comment '菜单图标',
create_time datetime comment '创建时间',
primary key (menu_id)
) comment = '菜单权限表';

核心字段解析

字段类型作用设计亮点
parent_idbigint(20)父菜单 ID实现树形结构,0 表示顶级菜单
menu_typechar(1)菜单类型M 目录、C 菜单、F 按钮,三级权限粒度
permsvarchar(100)权限标识system:user:add,用于按钮级权限控制
pathvarchar(200)路由地址对应前端路由的 path,如 /system/user
componentvarchar(255)组件路径前端组件的路径,如 system/user/index
is_cacheint(1)是否缓存控制页面是否使用 <keep-alive> 缓存

菜单类型(menu_type)详解

类型含义示例前端表现
M目录“系统管理”侧边栏的一级菜单,包含子菜单
C菜单“用户管理”可点击跳转的具体页面
F按钮“新增”、“删除”页面内的操作按钮,通过 v-hasPermi 指令控制显示

设计亮点

  1. 三级权限粒度:目录、菜单、按钮三层控制,实现了从页面到按钮的全链路权限管理。
  2. 权限标识规范perms 字段采用 模块:功能:操作 的命名规范(如 system:user:add),语义清晰且易于维护。
  3. 前后端一体化pathcomponent 字段存储了前端路由信息,后端直接返回给前端用于动态路由生成。

5.3.1.5. 关联表:用户-角色-菜单的桥梁

sys_user_role - 用户角色关联表

1
2
3
4
5
create table sys_user_role (
user_id bigint(20) not null comment '用户ID',
role_id bigint(20) not null comment '角色ID',
primary key(user_id, role_id)
) comment = '用户和角色关联表';

sys_role_menu - 角色菜单关联表

1
2
3
4
5
create table sys_role_menu (
role_id bigint(20) not null comment '角色ID',
menu_id bigint(20) not null comment '菜单ID',
primary key(role_id, menu_id)
) comment = '角色和菜单关联表';

设计亮点

  • 多对多关系:通过中间表实现用户与角色、角色与菜单的多对多映射。
  • 联合主键(user_id, role_id) 作为主键,天然避免重复数据,无需额外的唯一索引。
  • 查询性能:两字段联合主键会自动创建索引,极大提升关联查询性能。

权限判断流程

1
2
3
4
5
6
7
8
1. 查询用户ID = 1 的所有角色
SELECT role_id FROM sys_user_role WHERE user_id = 1

2. 根据角色ID查询所有菜单权限
SELECT menu_id FROM sys_role_menu WHERE role_id IN (...)

3. 根据菜单ID查询具体的权限标识
SELECT perms FROM sys_menu WHERE menu_id IN (...)

5.3.2. 组织架构表群:部门、岗位的树形管理

除了用户角色权限,若依还设计了完整的组织架构管理体系。

5.3.2.1. 部门表 (sys_dept)

表定义

1
2
3
4
5
6
7
8
9
10
11
12
create table sys_dept (
dept_id bigint(20) not null auto_increment comment '部门id',
parent_id bigint(20) default 0 comment '父部门id',
ancestors varchar(50) default '' comment '祖级列表',
dept_name varchar(30) default '' comment '部门名称',
order_num int(4) default 0 comment '显示顺序',
leader varchar(20) default null comment '负责人',
phone varchar(11) default null comment '联系电话',
status char(1) default '0' comment '部门状态(0正常 1停用)',
del_flag char(1) default '0' comment '删除标志(0存在 2删除)',
primary key (dept_id)
) comment = '部门表';

核心字段解析

字段作用设计亮点
parent_id父部门 ID实现树形结构,0 表示顶级部门
ancestors祖级列表核心优化字段,存储所有父级 ID,如 0,100,103

ancestors 字段的设计巧思

痛点:传统的树形结构查询,若要获取某个部门的所有上级部门,需要递归查询,性能极差。

解决方案ancestors 字段存储了从根节点到当前节点的完整路径。

示例数据

1
2
3
4
部门ID  | 部门名称    | parent_id | ancestors
100 | 若依科技 | 0 | 0
101 | 深圳总公司 | 100 | 0,100
103 | 研发部门 | 101 | 0,100,101

应用场景

  1. 查询所有上级部门:直接解析 ancestors 字符串即可,无需递归。
  2. 查询所有下级部门WHERE ancestors LIKE '%,103,%' 即可找到所有子孙部门。

5.3.2.2. 岗位表 (sys_post)

表定义

1
2
3
4
5
6
7
8
create table sys_post (
post_id bigint(20) not null auto_increment comment '岗位ID',
post_code varchar(64) not null comment '岗位编码',
post_name varchar(50) not null comment '岗位名称',
post_sort int(4) not null comment '显示顺序',
status char(1) not null comment '状态(0正常 1停用)',
primary key (post_id)
) comment = '岗位信息表';

用户与岗位关联表 (sys_user_post)

1
2
3
4
5
create table sys_user_post (
user_id bigint(20) not null comment '用户ID',
post_id bigint(20) not null comment '岗位ID',
primary key (user_id, post_id)
) comment = '用户与岗位关联表';

设计思想

  • 部门与岗位分离:部门代表组织层级(如 “研发部”),岗位代表职责类型(如 “项目经理”)。
  • 一个用户可以属于一个部门,但可以兼任多个岗位。

5.3.3. 系统功能表群:日志、字典、配置

5.3.3.1. 操作日志表 (sys_oper_log)

表定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
create table sys_oper_log (
oper_id bigint(20) not null auto_increment comment '日志主键',
title varchar(50) default '' comment '模块标题',
business_type int(2) default 0 comment '业务类型(0其它 1新增 2修改 3删除)',
method varchar(200) default '' comment '方法名称',
request_method varchar(10) default '' comment '请求方式',
oper_name varchar(50) default '' comment '操作人员',
oper_url varchar(255) default '' comment '请求URL',
oper_ip varchar(128) default '' comment '主机地址',
oper_param varchar(2000) default '' comment '请求参数',
json_result varchar(2000) default '' comment '返回参数',
status int(1) default 0 comment '操作状态(0正常 1异常)',
error_msg varchar(2000) default '' comment '错误消息',
oper_time datetime comment '操作时间',
cost_time bigint(20) default 0 comment '消耗时间',
primary key (oper_id),
key idx_sys_oper_log_bt (business_type),
key idx_sys_oper_log_s (status),
key idx_sys_oper_log_ot (oper_time)
) comment = '操作日志记录';

设计亮点

  1. 复合索引优化:在 business_typestatusoper_time 上建立索引,支持多维度日志查询。
  2. 性能追踪cost_time 字段记录接口响应时间,用于性能分析。
  3. 完整的请求链路:记录了方法名、URL、参数、返回值,方便问题追踪。

5.3.3.2. 字典表双表设计

sys_dict_type - 字典类型表

1
2
3
4
5
6
7
8
create table sys_dict_type (
dict_id bigint(20) not null auto_increment comment '字典主键',
dict_name varchar(100) default '' comment '字典名称',
dict_type varchar(100) default '' comment '字典类型',
status char(1) default '0' comment '状态(0正常 1停用)',
primary key (dict_id),
unique (dict_type)
) comment = '字典类型表';

sys_dict_data - 字典数据表

1
2
3
4
5
6
7
8
9
10
11
create table sys_dict_data (
dict_code bigint(20) not null auto_increment comment '字典编码',
dict_type varchar(100) default '' comment '字典类型',
dict_label varchar(100) default '' comment '字典标签',
dict_value varchar(100) default '' comment '字典键值',
dict_sort int(4) default 0 comment '字典排序',
css_class varchar(100) default null comment '样式属性',
list_class varchar(100) default null comment '表格回显样式',
status char(1) default '0' comment '状态(0正常 1停用)',
primary key (dict_code)
) comment = '字典数据表';

双表设计的优势

场景单表设计问题双表设计优势
类型管理无法统一管理字典类型sys_dict_type 可以启用/禁用整个字典类型
数据扩展新增字典项需要修改表结构sys_dict_data 灵活添加任意多个字典项
查询性能需要扫描所有字典数据通过 dict_type 外键快速定位

应用示例

1
2
3
4
5
-- 查询 "用户性别" 字典的所有选项
SELECT dict_label, dict_value
FROM sys_dict_data
WHERE dict_type = 'sys_user_sex' AND status = '0'
ORDER BY dict_sort;

5.3.4. Quartz 定时任务表群:企业级调度框架

若依集成了 Quartz 框架实现定时任务调度,Quartz 通过 11 张表管理任务的完整生命周期。

5.3.4.1. Quartz 核心表概览

表名作用核心程度
QRTZ_JOB_DETAILS存储任务详细信息⭐⭐⭐⭐⭐
QRTZ_TRIGGERS存储触发器信息⭐⭐⭐⭐⭐
QRTZ_CRON_TRIGGERS存储 Cron 表达式触发器⭐⭐⭐⭐
QRTZ_SIMPLE_TRIGGERS存储简单触发器⭐⭐⭐
QRTZ_FIRED_TRIGGERS存储正在执行的触发器⭐⭐⭐
QRTZ_SCHEDULER_STATE存储调度器状态⭐⭐⭐
QRTZ_LOCKS存储悲观锁信息⭐⭐
QRTZ_BLOB_TRIGGERS存储 Blob 类型触发器
QRTZ_CALENDARS存储日历信息
QRTZ_PAUSED_TRIGGER_GRPS存储暂停的触发器组
QRTZ_SIMPROP_TRIGGERS存储同步机制行锁

5.3.4.2. 任务详情表 (QRTZ_JOB_DETAILS)

表定义

1
2
3
4
5
6
7
8
9
10
create table QRTZ_JOB_DETAILS (
sched_name varchar(120) not null comment '调度名称',
job_name varchar(200) not null comment '任务名称',
job_group varchar(200) not null comment '任务组名',
job_class_name varchar(250) not null comment '执行任务类名称',
is_durable varchar(1) not null comment '是否持久化',
is_nonconcurrent varchar(1) not null comment '是否并发',
job_data blob null comment '存放持久化job对象',
primary key (sched_name, job_name, job_group)
) comment = '任务详细信息表';

核心字段解析

字段作用设计说明
job_class_name任务执行类存储 Java 类的全限定名,如 com.ruoyi.quartz.task.RyTask
is_nonconcurrent是否并发1 不允许并发执行,避免同一任务重复运行
job_data任务参数以 Blob 形式存储任务的上下文数据

5.3.4.3. 触发器表 (QRTZ_TRIGGERS)

表定义

1
2
3
4
5
6
7
8
9
10
11
12
13
create table QRTZ_TRIGGERS (
sched_name varchar(120) not null comment '调度名称',
trigger_name varchar(200) not null comment '触发器名称',
trigger_group varchar(200) not null comment '触发器组名',
job_name varchar(200) not null comment 'job_name外键',
job_group varchar(200) not null comment 'job_group外键',
next_fire_time bigint(13) null comment '下一次触发时间',
prev_fire_time bigint(13) null comment '上一次触发时间',
trigger_state varchar(16) not null comment '触发器状态',
trigger_type varchar(8) not null comment '触发器类型',
primary key (sched_name, trigger_name, trigger_group),
foreign key (sched_name, job_name, job_group) references QRTZ_JOB_DETAILS
) comment = '触发器详细信息表';

触发器状态(trigger_state

状态含义
WAITING等待触发
ACQUIRED已被调度器获取,准备执行
EXECUTING正在执行
PAUSED暂停
BLOCKED阻塞(上次执行未完成)
ERROR错误状态

5.3.4.4. Cron 触发器表 (QRTZ_CRON_TRIGGERS)

表定义

1
2
3
4
5
6
7
8
9
create table QRTZ_CRON_TRIGGERS (
sched_name varchar(120) not null comment '调度名称',
trigger_name varchar(200) not null comment 'trigger_name外键',
trigger_group varchar(200) not null comment 'trigger_group外键',
cron_expression varchar(200) not null comment 'cron表达式',
time_zone_id varchar(80) comment '时区',
primary key (sched_name, trigger_name, trigger_group),
foreign key (sched_name, trigger_name, trigger_group) references QRTZ_TRIGGERS
) comment = 'Cron类型的触发器表';

Cron 表达式示例

表达式含义
0/10 * * * * ?每 10 秒执行一次
0 0 2 * * ?每天凌晨 2 点执行
0 0 12 * * ?每天中午 12 点执行
0 0 10,14,16 * * ?每天 10 点、14 点、16 点执行

5.3.4.5. 调度器状态表 (QRTZ_SCHEDULER_STATE)

表定义

1
2
3
4
5
6
7
create table QRTZ_SCHEDULER_STATE (
sched_name varchar(120) not null comment '调度名称',
instance_name varchar(200) not null comment '实例名称',
last_checkin_time bigint(13) not null comment '上次检查时间',
checkin_interval bigint(13) not null comment '检查间隔时间',
primary key (sched_name, instance_name)
) comment = '调度器状态表';

集群支持:此表用于 Quartz 集群模式。多个应用实例共享同一数据库,通过此表实现:

  • 心跳检测:每个实例定期更新 last_checkin_time
  • 故障转移:如果某实例超过 checkin_interval 未更新,其他实例会接管其任务

5.3.5. 表设计亮点与最佳实践总结

通过对若依 30 张表的深度剖析,我们总结出以下数据库设计的最佳实践:

5.3.5.1. 设计模式亮点

设计模式应用表优势
逻辑删除sys_user, sys_role, sys_dept保留历史数据,支持数据恢复和审计
双表字典sys_dict_type + sys_dict_data类型与数据分离,易于管理和扩展
祖级路径sys_dept.ancestors优化树形结构查询,避免递归
联合主键sys_user_role, sys_role_menu天然防止重复,自动创建索引
多对多中间表所有关联表解耦实体关系,支持灵活的多对多映射

5.3.5.2. 性能优化策略

优化手段应用表效果
复合索引sys_oper_log支持多维度高效查询
外键约束QRTZ_TRIGGERS保证数据一致性
时间戳索引sys_oper_log.oper_time加速日志按时间范围查询
Blob 存储QRTZ_JOB_DETAILS.job_data灵活存储复杂对象

5.3.5.3. 安全与审计

安全措施应用表作用
密码加密存储sys_user.password存储 BCrypt 密文,防止密码泄露
密码更新时间sys_user.pwd_update_date支持密码过期策略
操作日志完整记录sys_oper_log记录请求参数和返回值,方便审计
登录日志sys_logininfor记录登录 IP 和时间,防止异常登录

5.3.5.4. 扩展性设计

扩展能力设计支撑应用场景
数据权限灵活配置sys_role.data_scope + sys_role_dept支持 5 种数据权限范围
菜单动态扩展sys_menu 树形结构无限层级的菜单体系
字典类型自由添加sys_dict_type + sys_dict_data无需修改代码即可新增字典
定时任务灵活调度Quartz 11 张表支持 Cron、简单触发器等多种模式

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

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 文件的手动构建工作已全部完成。


第七章. 后端服务构建:从零实现课程管理 API

在第六章中,我们以一名纯粹前端工程师的视角,从零构建了“课程管理”模块的用户界面 (index.vue)。我们精心封装了 API 服务 (course.js),但这些 API 调用目前还只是指向一个“虚空”的后端。我们的前端应用,虽然拥有了华丽的“皮囊”,却没有为其提供数据和逻辑的“灵魂”。

本章,我们将转换角色,戴上后端工程师的帽子。我们的核心任务是,在若依后端项目中,手动、完整地实现 前端所需的所有 API 接口。我们将不再依赖代码生成器,而是亲手编写每一层代码,旨在彻底揭开若依后端服务的“黑盒”,让您深刻理解一个 HTTP 请求是如何在后端被处理、与数据库交互并最终返回响应的。

我们为什么要手动实现?
代码生成器是生产力工具,它能快速生成遵循若依最佳实践的代码。但“知其然”更要“知其所以然”。通过手动实现一遍,我们将能精准地掌握:

  1. 三层架构的职责边界: Controller, Service, Mapper 各司其职,如何协作?
  2. 若依核心组件的运用: 分页插件 PageHelper、权限注解 @PreAuthorize、标准响应体 TableDataInfo 等是如何在真实业务中发挥作用的。
  3. MyBatis 的精髓: 动态 SQL 是如何构建灵活查询的。这将赋予您超越“代码生成器使用者”的、真正进行深度定制和二次开发的能力。

在开始编码之前,我们必须先建立起清晰的全局视野。以下目录树展示了本章我们即将在 ruoyi-admin 模块中创建的 全部文件 及其在项目中的标准位置。这便是我们本章的“施工图”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 路径: ruoyi-admin/
└── src/
└── main/
├── java/
│ └── com/
│ └── ruoyi/
│ └── course/
│ ├── domain/
│ │ └── TbCourse.java # <-- 1. 实体类 (Domain/POJO),与数据库 tb_course 表结构一一对应
│ ├── mapper/
│ │ └── TbCourseMapper.java # <-- 2. Mapper 接口,定义数据库原子操作的方法
│ ├── service/
│ │ └── ITbCourseService.java # <-- 3. Service 接口,定义核心业务逻辑
│ │ └── impl/
│ │ └── TbCourseServiceImpl.java # <-- 4. Service 实现类,编排业务流程
│ └── controller/
│ └── TbCourseController.java # <-- 5. Controller 类,暴露 HTTP API 接口给前端
└── resources/
└── mapper/
└── course/
└── TbCourseMapper.xml # <-- 6. MyBatis XML,编写与 Mapper 接口方法对应的具体 SQL

我们将遵循业界标准的 自底向上 的开发策略,这种方式能确保我们的依赖层总是先于使用层被构建,逻辑递进最为清晰:

  1. 7.1. 数据访问层 (Mapper): 我们将首先构建与数据库直接交互的 Mapper,它是所有上层建筑的基石。
  2. 7.2. 业务逻辑层 (Service): 在 Mapper 提供的原子数据操作之上,我们将编排和实现核心的业务流程。
  3. 7.3. 控制器层 (Controller): 最后,我们将构建 Controller,将内部的业务服务以标准、安全的 RESTful API 形式暴露给前端。

现在,让我们从最基础、也最重要的数据访问层开始。


7.1. 数据访问层 (Mapper)

7.1.1. 任务目标

本节的核心任务是构建 **数据访问层 **。这是后端三层架构中最底层、最接近数据库的一层,扮演着“数据搬运工”的角色。我们将手动编写 Mapper 接口及其对应的 XML 映射文件,创建一组方法,用于执行针对 tb_course 表的原子化 SQL 操作(增、删、改、查)。这一层是整个后端服务的数据基石,其质量直接决定了上层业务的稳定性和性能。


7.1.2. 前置工作:创建实体类 (Domain)

在编写 Mapper 之前,我们需要先创建一个 Java 类来承载从 tb_course 表中查询出的数据。这个类通常被称为 实体类 (Entity)领域对象 (Domain Object)POJO (Plain Old Java Object)

它的字段必须与 tb_course 表的列一一对应。

1. 文件创建
com.ruoyi.course.domain 包下创建 TbCourse.java 文件。

文件路径: ruoyi-admin/src/main/java/com/ruoyi/course/domain/TbCourse.java

2. 完整代码

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
package com.ruoyi.course.domain;

import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;

/**
* 课程管理对象 tb_course
*
* @author Prorise
* @date 2025-11-03
*/
public class TbCourse extends BaseEntity
{
private static final long serialVersionUID = 1L;

/** 课程 id */
private Long id;

/** 课程编码 */
@Excel(name = "课程编码")
private String code;

/** 课程学科(0: JavaEE 1: Python 2: 鸿蒙) */
@Excel(name = "课程学科", readConverterExp = "0=JavaEE,1=Python,2=鸿蒙")
private String subject;

/** 课程名称 */
@Excel(name = "课程名称")
private String name;

/** 价格 */
@Excel(name = "价格")
private BigDecimal price;

/** 适用人群 */
@Excel(name = "适用人群")
private String applicablePerson;

/** 课程介绍 */
@Excel(name = "课程介绍")
private String info;

// Setters and Getters...

@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
.append("id", getId())
.append("code", getCode())
.append("subject", getSubject())
.append("name", getName())
.append("price", getPrice())
.append("applicablePerson", getApplicablePerson())
.append("info", getInfo())
.append("createTime", getCreateTime())
.append("updateTime", getUpdateTime())
.toString();
}
}
  • extends BaseEntity: 继承了若依的 BaseEntity,可以复用其中定义的 createTime, updateTime 等通用字段。
  • @Excel 注解: 这是若依为“导出 Excel”功能提供的自定义注解。它标记了哪些字段需要被导出,name 属性定义了 Excel 中的列标题,readConverterExp 则实现了导出时的数据字典自动转换。

image-20240515203531887


7.1.3. 编写 TbCourseMapper 接口

Mapper 接口定义了数据访问的“契约”,即上层(Service)可以调用的方法。

文件路径: ruoyi-admin/src/main/java/com/ruoyi/course/mapper/TbCourseMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.ruoyi.course.mapper;
// ... (imports) ...
public interface TbCourseMapper
{
// 1. 根据 ID 查询
public TbCourse selectTbCourseById(Long id);

// 2. 条件分页查询
public List<TbCourse> selectTbCourseList(TbCourse tbCourse);

// 3. 新增
public int insertTbCourse(TbCourse tbCourse);

// 4. 修改
public int updateTbCourse(TbCourse tbCourse);

// 5. 根据 ID 删除
public int deleteTbCourseById(Long id);

// 6. 批量删除
public int deleteTbCourseByIds(Long[] ids);
}

7.1.4. 编写 TbCourseMapper.xml

这是本节的 核心。我们将在这里为 Mapper 接口中的每一个方法编写对应的 SQL 语句。我们将深入分析每个 SQL 标签的功能。

文件路径: ruoyi-admin/src/main/resources/mapper/course/TbCourseMapper.xml

A. 文件头与可复用元素

在开始编写具体方法前,我们先定义好“命名空间”和“可复用模块”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.ruoyi.course.mapper.TbCourseMapper">

<resultMap type="TbCourse" id="TbCourseResult">
<result property="id" column="id" />
<result property="code" column="code" />
<result property="subject" column="subject" />
<result property="name" column="name" />
<result property="price" column="price" />
<result property="applicablePerson" column="applicable_person" />
<result property="info" column="info" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>

<sql id="selectTbCourseVo">
select id, code, subject, name, price, applicable_person, info, create_time, update_time from tb_course
</sql>

</mapper>

B. 查询方法 (Select)

1. selectTbCourseList (核心:动态条件查询)

  • 对应接口: public List<TbCourse> selectTbCourseList(TbCourse tbCourse);
  • 功能: 这是最复杂的查询,用于支持前端的“搜索”功能。用户可能只填写“课程名称”,也可能同时选择“学科”,所以 SQL 语句的 WHERE 条件必须是动态生成的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<select id="selectTbCourseList" parameterType="TbCourse" resultMap="TbCourseResult">
<include refid="selectTbCourseVo"/>

<where>
<if test="code != null and code != ''">
and code = #{code}
</if>
<if test="subject != null and subject != ''"> and subject = #{subject}</if>

<if test="name != null and name != ''">
and name like concat('%', #{name}, '%')
</if>
<if test="applicablePerson != null and applicablePerson != ''"> and applicable_person = #{applicablePerson}</if>
</where>
</select>

解析 <where> 标签:
这是一个“智能”标签。它知道如果内部的 <if> 至少有一个成立,它就会在最前面插入一个 WHERE 关键字。更重要的是,它会自动 剔除 第一个 <if> 条件成立时,多余的 and 前缀。


2. selectTbCourseById (标准按 ID 查询)

  • 对应接口: public TbCourse selectTbCourseById(Long id);
  • 功能: 通过主键 ID 获取唯一的课程信息。
1
2
3
4
<select id="selectTbCourseById" parameterType="Long" resultMap="TbCourseResult">
<include refid="selectTbCourseVo"/>
where id = #{id}
</select>

C. 插入方法 (Insert)

insertTbCourse (核心:动态字段插入)

  • 对应接口: public int insertTbCourse(TbCourse tbCourse);
  • 功能: 插入一条新的课程数据。核心在于“动态”:只插入用户传入了值的字段,没有传入的字段(如 info 可能为空)则不出现在 INSERT 语句中,让数据库自动使用默认值。
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
<insert id="insertTbCourse" parameterType="TbCourse" useGeneratedKeys="true" keyProperty="id">
insert into tb_course

<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="code != null and code != ''">code,</if>
<if test="subject != null and subject != ''">subject,</if>
<if test="name != null and name != ''">name,</if>
<if test="price != null">price,</if>
<if test="applicablePerson != null and applicablePerson != ''">applicable_person,</if>
<if test="info != null">info,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
</trim>

<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="code != null and code != ''">#{code},</if>
<if test="subject != null and subject != ''">#{subject},</if>
<if test="name != null and name != ''">#{name},</if>
<if test="price != null">#{price},</if>
<if test="applicablePerson != null and applicablePerson != ''">#{applicablePerson},</if>
<if test="info != null">#{info},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
</trim>
</insert>

解析 <trim> 标签 (用于 Insert):
这是 MyBatis 中最灵活的动态 SQL 标签。

  1. 这两个 <trim> 块中的 <if> 判断条件 必须完全一致,才能保证列和值一一对应。
  2. suffixOverrides="," 是精髓所在。它解决了最后一个 <if> 成立时,SQL 语句末尾会多出一个 , 导致的语法错误。
  3. 这种写法,使得 INSERT 语句具有极高的灵活性,完美适配各种“可选字段”的插入场景。

D. 修改方法 (Update)

updateTbCourse (核心:动态字段更新)

  • 对应接口: public int updateTbCourse(TbCourse tbCourse);
  • 功能: 根据 ID 更新课程信息。核心在于“动态”:只更新用户传入了值的字段,未传入的字段(为 null)则不应被更新(即保持数据库原值)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<update id="updateTbCourse" parameterType="TbCourse">
update tb_course

<trim prefix="SET" suffixOverrides=",">
<if test="code != null and code != ''">code = #{code},</if>
<if test="subject != null and subject != ''">subject = #{subject},</if>
<if test="name != null and name != ''">name = #{name},</if>
<if test="price != null">price = #{price},</if>
<if test="applicablePerson != null and applicablePerson != ''">applicable_person = #{applicablePerson},</if>
<if test="info != null">info = #{info},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</trim>

where id = #{id}
</update>

深度解析 <trim> 标签 (用于 Update):

  1. 这解决了 UPDATE 的两大痛点:
    a. 避免更新空值: 如果不使用动态 SQL,UPDATE ... SET name=null 这样的语句会把数据库的旧值冲刷掉。
    b. 处理逗号: suffixOverrides="," 自动处理最后一个 SET 字段后面多余的逗号。
  2. prefix="SET" 保证了只有在 至少一个 <if> 成立时,才会加上 SET 关键字,避免了无字段更新时 UPDATE tb_course WHERE id = ... 的语法错误。

E. 删除方法 (Delete)

1. deleteTbCourseById (标准按 ID 删除)

  • 对应接口: public int deleteTbCourseById(Long id);
  • 功能: 删除单条记录。
1
2
3
<delete id="deleteTbCourseById" parameterType="Long">
delete from tb_course where id = #{id}
</delete>

2. deleteTbCourseByIds (核心:批量删除)

  • 对应接口: public int deleteTbCourseByIds(Long[] ids);
  • 功能: 根据前端传来的 ID 数组(例如 [1, 2, 3]),批量删除多条记录。
1
2
3
4
5
6
7
<delete id="deleteTbCourseByIds" parameterType="String">
delete from tb_course where id in

<foreach item="id" collection="array" open="(" separator="," close=")">
#{id}
</foreach>
</delete>

解析 <foreach> 标签:
这个标签是批量操作的利器。如果传入的 ids[1, 5, 9]<foreach> 标签会自动将 SQL 拼接为:delete from tb_course where id in (1, 5, 9)
这是一个单独执行的、高效的 SQL 语句,远胜于在 Java 中循环调用 deleteTbCourseById


7.2. 业务逻辑层 (Service)

7.2.1. 任务目标与设计哲学

7.1 节,我们构建了与数据库直接交互的 Mapper 层。现在,我们将进入后端三层架构的核心——业务逻辑层 (Service Layer)

Service 层是连接 ControllerMapper 的桥梁,它的核心职责不再是单纯的数据读写,而是 编排和实现具体的业务规则。我们将在这里,深度利用若依框架提供的各种工具类和设计模式,构建一个健壮、可维护的业务服务。

我们将严格遵循 面向接口编程 的设计范式,先定义 ITbCourseService 接口作为“业务契约”,再创建 TbCourseServiceImpl 实现类来完成“契约”的具体内容。


7.2.2. 编写 ITbCourseService 接口

接口文件定义了“课程管理”模块能对外提供的所有业务能力。

文件路径: ruoyi-admin/src/main/java/com/ruoyi/course/service/ITbCourseService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.ruoyi.course.service;

import java.util.List;

import com.ruoyi.course.domain.TbCourse;

/**
* 课程管理 Service 接口
* @author Prorise
* @date 2025-11-03
*/
public interface ITbCourseService {
public TbCourse selectTbCourseById(Long id);

public List<TbCourse> selectTbCourseList(TbCourse tbCourse);

public int insertTbCourse(TbCourse tbCourse);

public int updateTbCourse(TbCourse tbCourse);

public int deleteTbCourseByIds(Long[] ids);

public int deleteTbCourseById(Long id);
}

7.2.3. 编写 Impl 实现类

这是本节的 核心。我们将一步步构建这个实现类,并在每一步中,详细解析若依框架提供的特色工具是如何帮助我们提升开发效率和代码质量的。

1. 搭建基础结构与依赖注入

首先,我们创建 TbCourseServiceImpl.java 文件,并实现 ITbCourseService 接口。然后,注入我们底层依赖的 TbCourseMapper

文件路径: ruoyi-admin/src/main/java/com/ruoyi/course/service/impl/TbCourseServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.ruoyi.course.service.impl;

import java.util.List;
// 稍后我们将在这里引入若依的工具类
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.course.mapper.TbCourseMapper;
import com.ruoyi.course.domain.TbCourse;
import com.ruoyi.course.service.ITbCourseService;

/**
* 课程管理 Service 业务层处理
*/
@Service
public class TbCourseServiceImpl implements ITbCourseService
{
@Autowired
private TbCourseMapper tbCourseMapper;

// 后续方法将在这里逐一实现...
}

2. 实现查询方法 (select)

查询方法通常是业务层最直接的部分,它们现阶段主要是对 Mapper 方法的透传调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ... (依赖注入) ...

/**
* 根据 ID 查询课程详情
* 目前是直接调用 Mapper,但在复杂业务中,此方法是添加缓存逻辑 (如 Redis) 的最佳位置。
*/
@Override
public TbCourse selectTbCourseById(Long id)
{
return tbCourseMapper.selectTbCourseById(id);
}

/**
* 查询课程列表
* 同样是透传调用,未来可在此处对查询结果进行二次加工或数据脱敏。
*/
@Override
public List<TbCourse> selectTbCourseList(TbCourse tbCourse)
{
return tbCourseMapper.selectTbCourseList(tbCourse);
}

至此,我们的“读”(Read)操作已经完成。

3. 实现新增方法 (insert) 并应用若依工具

现在我们来实现 insertTbCourse 方法。这不再是简单的透传,我们需要在这里 注入业务规则

业务规则: 任何一条课程记录在被创建时,其 create_time 字段都应自动被设置为当前的服务器时间。

我们引入若依 common 模块下的 DateUtils 工具类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 在文件顶部 import 区域添加:
import com.ruoyi.common.utils.DateUtils;

// ... (class and other methods) ...

/**
* 新增课程
* Service 层的核心职责体现:在调用 Mapper 前,执行业务逻辑(填充默认值)。
*/
@Override
public int insertTbCourse(TbCourse tbCourse)
{
// 核心步骤: 调用若依提供的 DateUtils.getNowDate() 工具方法获取当前时间
// 这个工具类封装了 Java 8 的日期时间 API,提供了统一、便捷的时间获取方式。
// 将这个逻辑放在 Service 层,保证了业务规则的内聚性,Controller 层无需关心此细节。
tbCourse.setCreateTime(DateUtils.getNowDate());

// 调用持久层,将填充好默认值的对象存入数据库
return tbCourseMapper.insertTbCourse(tbCourse);
}

若依工具类: DateUtils

  • 位置: ruoyi-common/src/main/java/com/ruoyi/common/utils/DateUtils.java
  • 价值: 它统一了整个项目的日期和时间处理方式,避免了在代码中散落各种 new Date()LocalDateTime.now(),保证了格式和时区的一致性。getNowDate() 返回的是一个 java.util.Date 对象,与数据库的 datetime 类型兼容。这是若依“约定优于配置”思想的体现。

4. 实现修改方法 (update)

与新增类似,修改操作也需要注入业务规则。

业务规则: 任何一条课程记录在被修改时,其 update_time 字段都应自动被设置为当前的服务器时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ... (insertTbCourse method) ...

/**
* 修改课程
* 同样体现了 Service 层的业务职责:填充更新时间。
*/
@Override
public int updateTbCourse(TbCourse tbCourse)
{
// 核心步骤: 再次使用 DateUtils 工具类来设置更新时间。
// 保证了与创建时间逻辑的一致性和代码的规范性。
tbCourse.setUpdateTime(DateUtils.getNowDate());

return tbCourseMapper.updateTbCourse(tbCourse);
}

5. 实现删除方法 (delete)

删除操作目前是直接透传,但在复杂业务中,这里是添加 删除前置校验 的最佳位置(例如,检查该课程是否有关联的学生订单,若有则不允许删除)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    // ... (updateTbCourse method) ...

/**
* 批量删除课程
*/
@Override
public int deleteTbCourseByIds(Long[] ids)
{
return tbCourseMapper.deleteTbCourseByIds(ids);
}

/**
* 删除单条课程信息
*/
@Override
public int deleteTbCourseById(Long id)
{
return tbCourseMapper.deleteTbCourseById(id);
}
}

7.3. 控制器层 (Controller): 暴露 HTTP 接口

7.3.1. 任务目标

至此,我们已经拥有了功能完备的 Mapper (数据访问) 和 Service (业务逻辑)。现在,我们来到了将内部服务“暴露”给外部世界的最后一站——控制器层 (Controller Layer)

本节的核心任务是,手动编写 TbCourseController.java,创建一个符合 RESTful 风格的 API 控制器。它将扮演“交通枢纽”的角色,负责:

  1. 接收前端 HTTP 请求: 解析 URL、请求方法、参数和请求体。
  2. 调用业务服务: 将解析后的数据传递给 Service 层进行处理。
  3. 构建标准响应: 将 Service 层返回的结果,封装成统一、规范的 JSON 格式返回给前端。

我们将重点学习并应用若依框架在 Controller 层提供的 三大特色“利器”权限控制分页处理日志记录


7.3.2. 编写 TbCourseController (渐进式构建)

1. 搭建基础结构与依赖注入

首先,我们创建 TbCourseController.java 文件,并为其添加 Spring MVC 的核心注解,同时注入我们刚刚完成的 ITbCourseService

文件路径: ruoyi-admin/src/main/java/com/ruoyi/course/controller/TbCourseController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.ruoyi.course.controller;

import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.course.service.ITbCourseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* 课程管理 Controller
* @author Prorise
* @date 2025-11-03
*/
@RestController
@RequestMapping("/course/Course")
public class TbCourseController extends BaseController
{
@Autowired
private ITbCourseService tbCourseService;

// API 方法将在这里逐一实现...
}
  • extends BaseController: 继承若依的 BaseController 是关键。我们将从中获得大量便捷的工具方法,如 startPage(), getDataTable(), toAjax() 等。

2. 实现列表查询 (list) 方法

这是最能体现若依框架便捷性的一个方法。我们将在这里一次性集成 权限控制分页处理 两大功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ... (依赖注入) ...

/**
* 查询课程管理列表
*/
@PreAuthorize("@ss.hasPermi('course:course:list')")
@GetMapping("/list")
public TableDataInfo list(TbCourse tbCourse)
{
// 步骤 1: 调用 BaseController 提供的 startPage() 方法
startPage();

// 步骤 2: 调用 Service 层获取数据列表
List<TbCourse> list = tbCourseService.selectTbCourseList(tbCourse);

// 步骤 3: 调用 BaseController 提供的 getDataTable() 方法封装响应
return getDataTable(list);
}
  • startPage();:

    • 这是若依的分页处理核心。此方法继承自 BaseController
    • 工作机制: 它内部会从前端请求中解析出 pageNumpageSize 等分页参数,然后调用 PageHelper.startPage() 方法。PageHelper 会将这些分页信息存入一个 ThreadLocal 变量中。这意味着,这个分页设置 只对接下来执行的第一条 MyBatis 查询有效
  • List<TbCourse> list = tbCourseService.selectTbCourseList(tbCourse);:

    • 执行正常的业务查询。此时,MyBatis 的分页插件 PageHelper 的拦截器会自动生效,它会拦截这条即将执行的 SQL,并根据 ThreadLocal 中的分页信息,自动在原始 SQL 的末尾拼接上 LIMIT 子句(如 LIMIT 0, 10),从而实现物理分页。
  • return getDataTable(list);:

    • 这是若依的标准分页响应封装。此方法同样继承自 BaseController
    • 工作机制: 它接收经过分页查询后的 List 结果(这个 List 实际上是 PageHelper 返回的一个特殊子类 Page,其中包含了总记录数等信息)。getDataTable 会从中提取出当前页的数据列表和总记录数 total,然后封装成一个 TableDataInfo 对象。
    • 最终效果: 该对象被 @RestController 序列化后,生成了前端所期望的 { "code": 200, "msg": "查询成功", "rows": [...], "total": 20 } 这种标准 JSON 格式。

3. 实现增、删、改方法

这些方法相对简单,但同样集成了若依的 日志记录统一结果封装 功能。

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
// ... (list method) ...

/**
* 获取课程详细信息
*/
@PreAuthorize("@ss.hasPermi('course:course:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return AjaxResult.success(tbCourseService.selectTbCourseById(id));
}

/**
* 新增课程
*/
@PreAuthorize("@ss.hasPermi('course:course:add')")
@Log(title = "课程管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TbCourse tbCourse)
{
return toAjax(tbCourseService.insertTbCourse(tbCourse));
}

/**
* 修改课程
*/
@PreAuthorize("@ss.hasPermi('course:course:edit')")
@Log(title = "课程管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TbCourse tbCourse)
{
return toAjax(tbCourseService.updateTbCourse(tbCourse));
}

/**
* 删除课程
*/
@PreAuthorize("@ss.hasPermi('course:course:remove')")
@Log(title = "课程管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(tbCourseService.deleteTbCourseByIds(ids));
}
  • @Log(title = "课程管理", businessType = BusinessType.INSERT):

    • 这是若依的操作日志记录功能@Log 是一个自定义注解。
    • 工作机制: 一个 AOP 切面 (LogAspect) 会拦截所有带 @Log 注解的方法。在方法执行完毕后,切面会异步地收集本次操作的各种信息(如模块标题、操作类型、请求 URL、方法名、参数、操作人 IP、耗时等),并将它们封装成一个 SysOperLog 对象,最终存入 sys_oper_log 数据库表中。
    • 优势: 以非侵入式的方式,轻松实现了对所有关键操作的审计和追溯功能,极大提升了系统的安全性。businessType 是一个枚举,定义了操作的类型。
  • return toAjax(tbCourseService.insertTbCourse(tbCourse));:

    • toAjax() 方法继承自 BaseControllerService 层的增删改方法返回的是受影响的行数 (int)。toAjax 的逻辑很简单:
    • return rows > 0 ? AjaxResult.success() : AjaxResult.error();
    • 作用: 这是一个便捷的转换器,将业务层返回的 int 结果,转换成前端需要的、标准的 { "code": 200, "msg": "操作成功" }{ "code": 500, "msg": "操作失败" } 格式的 AjaxResult 对象。

4. 实现导出方法 (export)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ... (remove method) ...

/**
* 导出课程列表
*/
@PreAuthorize("@ss.hasPermi('course:course:export')")
@Log(title = "课程管理", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TbCourse tbCourse)
{
List<TbCourse> list = tbCourseService.selectTbCourseList(tbCourse);
ExcelUtil<TbCourse> util = new ExcelUtil<TbCourse>(TbCourse.class);
util.exportExcel(response, list, "课程管理数据");
}

深度解析 ExcelUtil:

  • ExcelUtil 是若依 common-poi 模块中提供的 核心工具,它基于 Apache POI 库进行了深度封装。
  • 工作机制:
    1. new ExcelUtil<TbCourse>(TbCourse.class): 在实例化时,它会通过反射读取 TbCourse.class 中所有被 @Excel 注解标记的字段。
    2. util.exportExcel(response, list, "课程数据"): 此方法会:
      • 创建一个 Excel 工作簿。
      • 根据 @Excel 注解的 name 属性生成表头。
      • 遍历 list 集合,将每个 TbCourse 对象的数据填入对应的单元格。如果 @Excel 中定义了 readConverterExp(字典转换),它会自动进行值的转换。
      • 设置 HTTP 响应头(Content-Typeapplication/vnd.ms-excelContent-Dispositionattachment;filename=...)。
      • 将生成的 Excel 文件流写入 HttpServletResponse 的输出流中,从而触发浏览器的文件下载。

7.4 前后端交互全流程解析

想象一下,用户打开了“课程管理”页面,输入了课程名称“Java”,然后点击了“搜索”按钮。这个看似简单的操作,背后触发了一系列精妙的连锁反应。让我们来一步步追踪这个请求的生命周期。

第 1 步:前端 Vue 组件发起请求

一切始于 index.vue。当用户点击搜索,getList() 方法被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/** 查询课程管理列表 */
async function getList() {
// 思考:为什么第一步是 loading.value = true?
// 这是为了提供即时的用户反馈,告知用户“系统正在处理您的请求”,避免用户因页面无响应而重复点击。
loading.value = true;

// 核心:调用封装好的 API 方法,将响应式对象 queryParams.value 作为参数传入
const res = await listCourse(queryParams.value);

// 将后端返回的数据,赋值给页面上的响应式变量
courseList.value = res.rows;
total.value = res.total;

// 数据渲染完成后,关闭加载状态
loading.value = false;
}

此时,queryParams.value 可能看起来是这样的:{ pageNum: 1, pageSize: 10, name: 'Java', ... }。这个对象被传递给了我们的 API 服务层。

第 2 步:API 层封装与代理转发

getList() 调用了在 course.js 中定义的 listCourse 函数。这一层是前端的“外交部”,专门负责与后端打交道。

1
2
3
4
5
6
7
8
// course.js
export function listCourse(query) {
return request({
url: '/course/course/list', // 请求的目标 API 地址
method: 'get',
params: query // 将 { pageNum: 1, ... } 拼接成 URL 参数,如 ?pageNum=1&pageSize=10&name=Java
})
}

关键问题:跨域

我们的前端(例如 http://localhost:80)和后端(http://localhost:8080)运行在不同的端口上,这构成了“跨域”。浏览器出于安全考虑,会默认阻止前端直接向后端发送请求。若依前端项目是如何解决这个问题的呢?

答案就在于 开发服务器代理 (Proxy)

image-20240515203936120

这段配置告诉 vue-cli 的开发服务器:

“任何发往 /prod-api 的请求,都不要真的发往 /prod-api。请你(开发服务器)代我将这个请求转发到 http://localhost:8080,并且在转发时,请把路径中的 /prod-api 去掉。”

因此,前端代码中看似请求了 /prod-api/course/course/list,实际上经过代理转发,最终到达后端服务器的请求是 GET http://localhost:8080/course/course/list?pageNum=1&...。这样就巧妙地绕过了浏览器的同源策略限制。

第 3 步:后端 Controller 层接收与处理

请求成功抵达若依后端。Spring MVC 框架根据请求的 URL (/course/course/list) 和 HTTP 方法 (GET),精准地将其路由到 TbCourseControllerlist 方法。

1
2
3
4
5
6
7
8
9
10
11
12
// TbCourseController.java
@PreAuthorize("@ss.hasPermi('course:course:list')") // 1. 权限校验
@GetMapping("/list")
public TableDataInfo list(Course course) // 2. 参数绑定
{
// 3. 开启分页
startPage();
// 4. 调用业务层
List<Course> list = courseService.selectCourseList(course);
// 5. 封装并返回
return getDataTable(list);
}

这里的每一步都体现了若依框架的设计精髓:

  1. 权限校验先行:在执行任何业务逻辑之前,@PreAuthorize 注解首先会利用 Spring Security 检查当前登录用户是否拥有 course:course:list 这个权限标识。如果没有,请求将被直接拒绝,返回 403 错误。
  2. 参数自动绑定:Spring MVC 会自动将 URL 中的查询参数(name=Java, pageNum=1 等)与 Course 对象的属性进行匹配和赋值。
  3. 声明式分页startPage() 是一个神奇的方法。它并不执行查询,而是从请求中提取分页参数,并将它们存入一个线程级别的变量中。这为后续的数据库查询埋下了“伏笔”。
  4. 职责下放:Controller 不关心具体的查询逻辑,它只负责调度,将任务委托给 courseService
  5. 标准格式封装getDataTable(list) 会从 PageHelper 分页查询后的结果中,自动提取出列表数据和总条数,封装成前端需要的 { rows: [...], total: ... } 结构。

第 4 步:Service 层编排业务

Controller 调用了 TbCourseServiceImpl.selectCourseList()。在查询这个场景下,Service 层没有复杂的业务逻辑,所以它主要扮演了一个“管道工”的角色,直接将请求透传给 Mapper 层。

1
2
3
4
5
6
7
8
9
// TbCourseServiceImpl.java
@Override
public List<Course> selectCourseList(Course course)
{
// 思考:如果需求变更为“查询结果中,价格高于 10000 的课程需要特殊标记”,
// 那么这个逻辑应该写在哪里?
// 答案是:就应该写在这里。Service 层是处理这种业务规则的最佳位置。
return courseMapper.selectCourseList(course);
}

第 5 步:Mapper 层执行 SQL

这是与数据库交互的最后一环。courseMapper.selectCourseList(course) 的调用,会触发 MyBatis 框架去执行 TbCourseMapper.xml 中对应的 SQL 语句。

1
2
3
4
5
6
7
8
9
10
11
<!-- TbCourseMapper.xml -->
<select id="selectCourseList" parameterType="Course" resultMap="CourseResult">
<include refid="selectCourseVo"/>
<where>
<!-- 因为传入的 course 对象中 name 属性有值 ('Java'),所以这个 if 判断会成立 -->
<if test="name != null and name != ''">
and name like concat('%', #{name}, '%')
</if>
<!-- 其他参数若为 null 或空字符串,则对应的 if 不会成立 -->
</where>
</select>

此时,两个“魔法”同时发生:

  1. PageHelper 插件:在 MyBatis 执行这条 SQL 之前,分页插件的拦截器会生效。它发现之前调用了 startPage(),于是自动在这条 SQL 的末尾拼接上 LIMIT 子句,使其变成一条物理分页查询语句。
  2. 动态 SQL:MyBatis 根据传入的 Course 对象,动态地构建出 WHERE 子句。因为只有 name 字段有值,所以最终执行的 SQL 类似于:select ... from tb_course WHERE name like '%Java%' limit 0, 10

第 6 步:数据回流与前端渲染

数据库执行 SQL 后,将查询结果集返回给 MyBatis,MyBatis 将其映射为 List<Course> 对象。这个列表经历了回家的路:

Mapper -> Service -> Controller (被 getDataTable 封装成 TableDataInfo) -> Spring MVC (序列化为 JSON 字符串) -> 网络 -> 前端代理服务器 -> 浏览器

浏览器接收到 JSON 响应后,axiosPromise 进入 resolved 状态,getList 函数中的 await 结束等待,res 变量被赋值。

最后,courseList.value = res.rows;total.value = res.total; 这两行代码触发了 Vue 3 的响应式系统,页面上的表格和分页组件自动更新,向用户展示出经过筛选和分页的数据。loading.value = false; 则隐藏了加载动画。

至此,一次完整的前后端交互闭环圆满完成。


第八章. 进阶实战:系统定制、主子表深度改造

8.1. 系统定制与新模块工程准备

8.1.1. 痛点分析:为何要定制系统与创建独立模块?

需求分析

在我们开始深入的二次开发之前,必须先解决一个核心问题:“个性化”。

若依(RuoYi)作为一个优秀的项目脚手架,其代码生成器解决了“从 0 到 1”的效率问题。但它交付的产物是标准化的,带有强烈的“若依”烙印。而在真实的商业项目中,我们需要交付的是具有企业自身品牌标识、符合特定业务流程的系统。

学员

那么,具体体现在哪些方面呢?

主要体现在两个层面:

系统定制(品牌化):用户看到的每一个界面,从登录页的背景图、浏览器标签页的 Logo,到系统内的标题,都应该是我们企业的标识,而不是“若依”。这是交付专业产品的基本要求。

新模块工程(架构):若依默认将业务代码放在 ruoyi-system 或 ruoyi-admin 中。但随着业务(如订单、商品、物流)的膨胀,所有代码都堆积在一起,将导致灾难性的耦合。我们需要像搭建乐高一样,将“商品管理”拆分为独立的 ruoyi-goods 模块,将“订单管理”拆分为 ruoyi-order 模块。

本章,我们将首先解决“面子”问题(系统定制),然后搭建好“里子”工程(新模块准备),为后续的“主子表”与“树表”实战打下坚实的基础。


8.1.2. 系统品牌化:浏览器标题与 Favicon

浏览器标签页是用户感知系统的第一个触点。我们将从这里开始,替换掉若依的默认标识。

1. 替换 Favicon (标签页图标)

Favicon 是显示在浏览器标签页上的小图标。

  • 文件路径: public/favicon.ico

我们需要做的,就是将设计好的 favicon.ico 图标文件,直接替换掉 public 目录下的同名文件,且替换 logo 文件

image-20251105140909796

2. 修改系统标题

系统标题的修改分为两部分:主标题和动态标题。

A. 修改主标题 (index.html)

主标题是 index.html 文件中定义的静态标题,它是整个单页应用(SPA)的“HTML 宿主”标题。

  • 文件路径: index.html (项目根目录)

我们打开此文件,找到 <title> 标签:

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>若依管理系统</title> </head>
<body>
</body>
</html>

我们将其修改为我们自己的企业名称:

1
2
3
4
5
6
7
8
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Prorise 数字化管理平台</title> </head>
<body>
</body>
</html>

B. 修改动态标题 (环境变量)

仅仅修改 index.html 还不够。若依的路由守卫会在页面跳转时,动态地设置浏览器的标题(例如 “首页”、“用户管理”)。这些动态标题的后缀名(即系统总称)是由 环境变量 控制的。

  • 文件路径: .env.development.env.production (项目根目录)

我们打开这两个文件,修改 VITE_APP_TITLE 变量:

1
2
3
4
# .env.development (原始)
# ...
# 应用信息
VITE_APP_TITLE = 若依管理系统

修改为:

1
2
3
4
# .env.development (修改后)
# ...
# 应用信息
VITE_APP_TITLE = Prorise 数字化管理平台

注意: 务必同时修改 .env.development (开发环境) 和 .env.production (生产环境) 两个文件,确保所有环境下的标题统一。

完成这三步(favicon.ico, index.html, .env)替换后,系统在浏览器标签页的品牌标识就完全统一了。

image-20251105141023262


8.1.3. 登录页改造:重塑系统门面(标题与背景图)

登录页是系统的“门面”,是建立专业形象的关键。

  • 文件路径: src/views/login.vue

我们打开该组件文件,开始进行“装修”。

替换登录页背景图

login.vue 文件的 <style> 区域,我们可以找到定义背景图的 CSS。

1
2
3
4
5
6
7
8
9
10
11
12
<style lang='scss' scoped>
.login {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
// 关键样式:背景图
background-image: url("../assets/images/login-background.jpg");
background-size: cover;
}
/* ... 其他样式 ... */
</style>

若依将背景图放在了 src/assets/images/login-background.jpg。我们只需将自己准备好的背景图(例如 my-login-bg.png)放入 src/assets/images 目录中,然后修改这里的 url() 路径即可。

1
2
3
4
5
6
7
8
9
<style lang='scss' scoped>
.login {
/* ... */
// 修改背景图路径
background-image: url("../assets/images/my-login-bg.png");
background-size: cover;
}
/* ... */
</style>

8.1.4. 布局调整:移除“源码地址”与“文档”链接

移除“若依”标识

完成了 Logo 和标题,我们来处理最明显的“烙印”——顶部导航栏的“源码”和“文档”链接。

在一个正式的交付产品中,保留这些指向外部框架的链接会显得非常不专业,也是品牌化定制必须清除的部分。

学员

它们是硬编码在页面上的吗?

没错。它们是作为独立的组件被直接引入到主导航栏布局中的。我们的任务就是找到这个布局文件,并将它们移除。

1. 定位布局文件

这两个链接是顶部导航栏(Navbar)的一部分,它们被作为子组件引入。

  • 文件路径: src/layout/components/Navbar.vue

2. 渐进式修改

我们打开这个文件,在 <template> 区域的后半部分,会看到控制导航栏右侧菜单项的代码:

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div class="navbar">
<div class="right-menu">
<template v-if="appStore.device !== 'mobile'">
<header-search id="header-search" class="right-menu-item" />
<screenfull id="screenfull" class="right-menu-item hover-effect" />
<el-tooltip content="布局大小" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>

<ruoyi-git id="ruoyi-git" class="right-menu-item" />

<ruoyi-doc id="ruoyi-doc" class="right-menu-item" />
</template>

<div class="avatar-container">
</div>
</div>
</div>
</template>

3. 分析与移除

我们的目标是 <ruoyi-git><ruoyi-doc> 这两个非标准的 HTML 标签。

  • <ruoyi-git>:此组件用于渲染指向 RuoYi 源码仓库(Gitee/GitHub)的图标链接。
  • <ruoyi-doc>:此组件用于渲染指向 RuoYi 官方文档的图标链接。

我们的操作非常简单:将这两行组件代码注释掉或直接删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="navbar">
<div class="right-menu">
<template v-if="appStore.device !== 'mobile'">
<header-search id="header-search" class="right-menu-item" />
<screenfull id="screenfull" class="right-menu-item hover-effect" />
<el-tooltip content="布局大小" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>

</template>

<div class="avatar-container">
</div>
</div>
</div>
</template>

保存文件后,重新访问页面(Vite 会自动热重载),您会发现顶部的这两个图标链接已经彻底消失了。导航栏变得更加清爽,也更符合我们自定义系统的要求。


8.1.5. 工具:若依框架修改器使用

深度定制的“捷径”

刚刚我们完成了前端的“表面”定制。接下来,我们要对后端项目进行更深层次的“烙印”去除。

学员

您是指后端的 Java 包名吗?比如 com.ruoyi

非常正确。在 Java 世界中,包名(Package Name)是代码的“身份证”,它通常反向绑定了开发者的域名,比如 com.google.gson

如果我们的交付产物中,所有 Java 类的包名都还是 com.ruoyi.xxx,这在技术层面上是不合格的白标产品。

学员

手动修改成百上千个文件里的包名和 import 语句,这听起来像个噩梦…

确实如此,而且极易出错。幸运的是,社区已经有了成熟的解决方案——“若依框架修改器 (RuoYi-MT)”。

这是一个专门用于一键批量替换包名的小工具(通常是一个 .jar 文件)。我们只需要提供原始项目(后端)的 zip 压缩包,并指定新的包名(例如 com.prorise.digital),它就能自动为我们生成一个全新的、已替换所有包名的 zip 包。

核心价值与使用时机

若依框架修改器(RuoYi-MT)解决的核心痛点,就是后端 Java 项目的包名批量替换

它的主要作用是将所有 import 语句、package 声明以及 XML 配置文件中对 com.ruoyi 的引用,全部替换为我们自定义的新包名。

img

实战操作:使用修改器

  1. 准备文件:从若依官网下载最新的后端代码 zip 压缩包(例如 https://gitee.com/y_project/RuoYi-Vue/tree/springboot3 ),不要解压
  2. 打开工具:运行 RuoYi-MT.jar 文件(或其 exe 版本)。
  3. 配置参数:参照下图和表格,填写所有字段。

我们以将项目修改为“Prorise 数字化平台”为例,填写配置如下:

img

界面字段示例值解释说明
选择压缩文件D:\download\\RuoYi-Vue-springboot3.zip选择您刚刚下载的 未解压 的若依后端 zip 包。
选择系列RuoYi-Vue必须勾选正确的项目系列(我们使用的是 RuoYi-Vue)。
目录名称Prorise-Digital最终生成的 zip 包解压后的根目录名称。
项目名prorise通常是公司或项目的英文简称(小写)。
包名com.prorise(核心) 替换 com.ruoyi 的新包名,通常是反向域名。
artifactIdproriseMaven 的 artifactId,与项目名保持一致。
groupIdcom.proriseMaven 的 groupId,与包名保持一致。
站点名称Prorise 数字化平台用于替换配置文件中“若依管理系统”的中文显示名称。
  1. 执行:点击 “开始执行” 按钮。工具会在操作记录中显示替换进度,完成后会提示“修改完成”。
  2. 获取产物:点击 “打开输出目录”,您会找到一个新生成的以时间戳作为命名 zip 文件,例如 20251105143707

这个新生成的 zip 包,就是我们接下来整个后端开发的基础。


8.1.6. 后端:创建 prorise-merchant 业务子模块

搭建业务模块

现在,我们使用 8.1.5 节产出的、包名已替换为 com.prorise 的新项目,开始进行架构层面的规划。

学员

我们的目标是添加“菜品管理”、“商品分类”等功能,这些代码应该放在哪里?

这是一个关键的架构决策。若依默认的 prorise-system 模块,其职责是管理用户、角色、菜单等系统级功能。

如果我们将所有新业务(如商品、订单、支付)的代码全部添加到 prorise-system 中,将导致该模块的职责不清、代码高度耦合,严重违反“高内聚、低耦合”的软件设计原则,给未来维护带来极大困难。

学员

我明白了。正确的做法是按业务领域划分模块。

完全正确。我们将创建 GIt 独立的 Maven 模块 prorise-merchant,专门用于承载所有“商户”相关的业务(如菜品、分类等),使其与系统核心功能在物理上隔离。

目标:创建一个名为 prorise-merchant 的独立 Maven 模块,用于存放所有商户及商品相关的业务代码。

步骤一:在 IDE 中创建新模块

我们以 IntelliJ IDEA 为例,演示如何在 Prorise-Digital (后端) 这个父工程下创建子模块。

  1. 在项目视图中,右键点击 Prorise-Digital 父工程的根目录。
  2. 选择 New -> Module…

步骤二:配置模块信息

打开 “New Module” 向导后,您会看到一个包含多种选项的界面,按照如下选项填入即可

img

步骤三:检视初始模块结构

IDEA 最终会创建出正确的模块结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Prorise-Digital/
├── prorise-admin/
├── prorise-common/
├── prorise-framework/
├── prorise-system/
├── ...
├── prorise-merchant/ <-- 这是我们新创建的模块
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/
│ │ │ └── resources/
│ │ └── test/
│ └── pom.xml <-- 新模块的 POM 文件
└── pom.xml <-- 父工程的 POM 文件

同时,IDEA 会自动打开 prorise-merchant 模块下的 pom.xml 文件。请检查该文件,确保 <parent> 标签已正确设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.prorise</groupId>
<artifactId>prorise</artifactId>
<version>3.9.0</version>
</parent>

<artifactId>prorise-merchant</artifactId>

<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>

步骤四:建立标准业务包结构

模块的骨架有了,我们还需要在 java 目录下建立清晰的包(Package)结构,以便后续存放代码。

  1. prorise-merchant/src/main/java 目录下,创建基础包:com.prorise.merchant
  2. com.prorise.merchant 包下,创建业务分层包:
    • controller (存放 Spring MVC 控制器)
    • domain (存放实体类 POJO)
    • mapper (存放 MyBatis Mapper 接口)
    • service (存放业务逻辑接口)
    • service.impl (存放业务逻辑实现)

最终的 Java 目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
prorise-merchant/
└── src/
└── main/
└── java/
└── com/
└── prorise/
└── merchant/
├── controller/
├── domain/
├── mapper/
└── service/
└── impl/

我们已经成功创建了独立的 prorise-merchant 模块。目前,该模块在工程结构层面存在两个关键问题:

  1. 父工程 Prorise-Digitalpom.xml 中尚未声明 prorise-merchant 作为其子模块。
  2. prorise-admin 启动模块尚未依赖 prorise-merchant,导致 Spring Boot 无法扫描并加载新模块中的 Bean。

步骤三:为 prorise-merchant 添加核心依赖

新创建的模块是空白的,它无法使用 prorise-framework 中提供的 Spring Security、MyBatis Plus、Redis 等核心封装。我们必须为其添加依赖。

文件路径Prorise-Digital/prorise-merchant/pom.xml

打开此文件,在 <properties> 标签后,添加 <dependencies> 标签,并引入 prorise-framework

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<properties>
...
</properties>
<!-- 添加这一行-->
<dependencies>

<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-framework</artifactId>
</dependency>

</dependencies>

</project>

8.1.7. 工程配置:父工程 pom.xml 声明与版本锁定

目标

  1. 在父 POM 的 <modules> 中声明 prorise-merchant
  2. 在父 POM 的 <dependencyManagement> 中锁定 prorise-merchantprorise-common 的版本。

背景:在 8.1.6 节中,我们创建了 prorise-merchant 模块。现在需要在父工程中对其进行“注册”和“管理”。

  • 声明 (<modules>):告知 Maven Prorise-Digital 是一个多模块项目,prorise-merchant 是其子模块之一。
  • 版本锁定 (<dependencyManagement>):统一管理所有模块的版本。在此处声明 prorise-merchant 的版本后,其他模块(如 prorise-admin)在引入它们时,就 无需 指定 <version> 标签,从而避免了版本冲突。

文件路径Prorise-Digital/pom.xml (项目根目录的 pom.xml)

步骤一:声明模块

定位到 <modules> 标签,添加 prorise-merchant

1
2
3
4
5
6
7
8
9
10
<modules>
<module>prorise-admin</module>
<module>prorise-framework</module>
<module>prorise-system</module>
<module>prorise-common</module>
<module>prorise-generator</module>
<module>prorise-quartz</module>

<module>prorise-merchant</module>
</modules>

步骤二:版本锁定

定位到 <dependencyManagement> 标签,在其中添加 prorise-merchant 的依赖声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-system</artifactId>
<version>${prorise.version}</version>
</dependency>

<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-common</artifactId>
<version>${prorise.version}</version>
</dependency>

<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-merchant</artifactId>
<version>${prorise.version}</version>
</dependency>

</dependencies>
</dependencyManagement>

8.1.8. 工程配置:prorise-admin 引入新模块依赖

目标prorise-admin 是项目的唯一启动入口。必须在 prorise-admin 模块中添加对 prorise-merchant 的依赖,Spring Boot 才能在启动时扫描并加载 prorise-merchant 中的所有组件(如 Controller, Service)。

文件路径Prorise-Digital/prorise-admin/pom.xml

打开此文件,定位到 <dependencies> 标签。

1
2
3
4
5
6
7
8
9
10
11
<dependencies>
<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-framework</artifactId>
</dependency>

<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-system</artifactId>
</dependency>
</dependencies>

我们在此处添加 prorise-merchant 的依赖声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependencies>
<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-framework</artifactId>
</dependency>

<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-system</artifactId>
</dependency>

<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-generator</artifactId>
</dependency>

<dependency>
<groupId>com.prorise</groupId>
<artifactId>prorise-merchant</artifactId>
</dependency>
</dependencies>

完成工程配置

保存所有修改过的 pom.xml 文件并刷新 Maven 依赖后,prorise-admin 启动模块现在就与 prorise-merchant 业务模块建立了依赖关系。

至此,新模块的工程配置全部完成,我们已经准备好在 prorise-merchant 模块中开发全新的业务功能了。


8.2. 主子表案例基础(菜品管理)

设定目标

我们的 prorise-merchant 模块工程已经搭建完毕。现在,我们将开始第一个核心实战:主子表功能

学员

什么是主子表?

主子表是企业开发中最常见的“一对多”数据模型。以“菜品管理”为例:

“菜品(Dish)”是 主表,它存储菜品的基本信息,如名称、价格、图片。

“口味(Flavor)”是 子表,一个菜品可以对应 个口味(如“辣度”、“忌口”)。

我们的目标是,在若依的代码生成器中,实现一个功能:当“新增/修改菜品”时,可以在同一个表单中,动态地添加、删除、修改其关联的“口味”列表。

在本节中,我们将完成实现该功能所需的所有“地基”工作。

8.2.1. 业务分析与数据建模

在启动代码生成器之前,我们必须先完成最关键的第一步:数据建模。代码生成器是“执行者”,而我们是“设计师”,它只能根据我们提供的“图纸”(数据库表结构)来工作。

我们的核心业务需求是“管理菜品”。一个“菜品”对象,包含了两种不同性质的数据:

  1. 单一属性(一对一):一个菜品只有一个“名称”、一个“价格”、一张“主图”。
  2. 复合属性(一对多):一个菜品可以拥有 多组“口味”。例如,“干锅牛蛙”这道菜,既有“辣度”属性(可选:微辣、中辣、重辣),又有“忌口”属性(可选:不要葱、不要蒜)。

为了在数据库中精确表达这种“一对多”的关系,我们不能(也绝不应该)在菜品表中设计 flavor1_nameflavor1_valueflavor2_name 这样的字段,这会导致表结构僵化且无法扩展。

标准的解决方案是使用 两张表 来描述这个模型:

  • 主表 tb_dish:用于存储菜品的“一对一”基础信息。
  • 子表 tb_dish_flavor:用于存储“一对多”的口味信息。

这两张表通过一个“外键”进行关联。子表 tb_dish_flavor 中的 dish_id 字段,将指向它所属的主表 tb_dishid

image-20240515205011305

实战操作:创建表结构

请在您的 MySQL 数据库(如 prorise_db)中执行以下 SQL 脚本,创建这两张表并插入用于后续开发的演示数据。

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
-- ----------------------------
-- 1. 菜品表(主表)
-- ----------------------------
DROP TABLE IF EXISTS `tb_dish`;
CREATE TABLE `tb_dish` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(32) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin NOT NULL COMMENT '菜品名称',
`price` decimal(10,2) DEFAULT NULL COMMENT '菜品价格',
`image` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin DEFAULT NULL COMMENT '图片',
`description` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin DEFAULT NULL COMMENT '描述信息',
`status` int DEFAULT '1' COMMENT '0 停售 1 起售',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_dish_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=111 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='菜品管理';

-- ----------------------------
-- 插入 `tb_dish` 演示数据
-- ----------------------------
INSERT INTO `tb_dish` VALUES
(30, '干锅牛蛙', 38.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/56395e06-6d86-4d16-8d0e-18b2da085b8a.jpg', '干锅牛蛙', 1, '2023-04-13 23:14:12', '2023-04-16 22:04:25'),
(50, '馒头', 1.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/dadaf543-b305-4139-9147-4e9f19f4c84c.jpg', '优质面粉', 1, '2022-06-10 09:34:28', '2023-04-14 09:34:45'),
(74, '黑金鲍鱼', 68.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/833cf1ae-0835-4278-a374-00395cd4cbe9.jpg', '新西兰黑金鲍鱼', 1, '2023-02-16 09:48:28', '2023-04-13 22:44:08'),
(75, '波士顿龙虾', 128.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/833cf1ae-0835-4278-a374-00395cd4cbe9.jpg', '2 斤重 波斯顿龙虾', 1, '2023-02-16 09:50:06', '2024-05-09 09:53:58'),
(76, '香辣烤乌江鱼 3 斤', 108.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/833cf1ae-0835-4278-a374-00395cd4cbe9.jpg', '香辣烤乌江鱼 3 斤', 1, '2023-02-16 09:52:30', '2023-04-13 22:41:29'),
(77, '香辣烤鱼 3 斤', 78.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/dadaf543-b305-4139-9147-4e9f19f4c84c.jpg', '香辣烤鱼 3 斤 草鱼', 1, '2023-02-17 15:27:02', '2023-04-13 22:39:20'),
(80, '宽粉', 8.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/c04d2598-0b96-44d7-8ea9-72792b9be66a.jpg', '宽粉', 0, '2023-04-13 22:48:57', '2023-04-15 13:45:03'),
(81, '青笋', 10.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/3685a52b-46ca-4e2a-8fd2-d90205fa9405.jpg', '青笋', 1, '2023-04-13 22:49:21', '2023-04-13 22:49:21'),
(82, '鲜豆皮', 8.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/c04d2598-0b96-44d7-8ea9-72792b9be66a.jpg', '鲜豆皮', 1, '2023-04-13 22:49:52', '2023-04-13 22:49:52'),
(83, '娃娃菜', 6.00, 'https://yjy-rjwm-oss.oss-cn-hangzhou.aliyuncs.com/0bf2be55-5ab3-4e4c-8b1c-98e17b515a7a.jpg', '娃娃菜', 1, '2023-04-13 22:50:26', '2023-04-13 22:48:23');

-- ----------------------------
-- 2. 菜品口味表(子表)
-- ----------------------------
DROP TABLE IF EXISTS `tb_dish_flavor`;
CREATE TABLE `tb_dish_flavor` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`dish_id` bigint NOT NULL COMMENT '菜品ID (外键)',
`name` varchar(32) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin DEFAULT NULL COMMENT '口味名称 (如: 辣度)',
`value` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin DEFAULT NULL COMMENT '口味列表 (如: [\'微辣\',\'中辣\'])',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=198 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin COMMENT='菜品口味关系表';

-- ----------------------------
-- 插入 `tb_dish_flavor` 演示数据
-- ----------------------------
INSERT INTO `tb_dish_flavor` VALUES (136, 77, '辣度','[\"不辣\",\"微辣\",\"中辣\",\"重辣\"]');
INSERT INTO `tb_dish_flavor` VALUES (137, 77, '忌口','[\"不要葱\",\"不要蒜\",\"不要香菜\",\"不要辣\"]');
INSERT INTO `tb_dish_flavor` VALUES (140, 75, '忌口','[\"不要葱\",\"不要蒜\",\"不要香菜\",\"不要辣\"]');
INSERT INTO `tb_dish_flavor` VALUES (141, 75, '辣度','[\"不辣\",\"中辣\",\"重辣\"]');
INSERT INTO `tb_dish_flavor` VALUES (142, 74, '忌口','[\"不要葱\",\"不要蒜\"]');
INSERT INTO `tb_dish_flavor` VALUES (143, 74, '甜味','[\"无糖\",\"少糖\",\"半糖\"]');

8.2.2. 代码生成器:导入表结构

现在,我们的“设计图纸”(数据库表)已经就绪。下一步是让若依的“代码生成器”读取这些图纸。

这个“导入”操作,本质上是若依框架在 逆向读取 数据库的 information_schema,获取指定表的元数据(MetaData),并将其存储在 gen_tablegen_table_column 这两张表中,以便后续进行更详细的配置。

实战操作:

  1. 登录若依后台管理界面。
  2. 导航至 系统工具 -> 代码生成
  3. 点击左上角的 “导入” 按钮。
  4. 系统会弹出一个窗口,显示当前数据库中所有尚未被代码生成器纳管的表。
  5. 在列表中找到我们刚刚创建的 tb_dishtb_dish_flavor
  6. 勾选 这两张表。
  7. 点击 “确定” 按钮。

结果验证:

导入成功后,您会看到 tb_dishtb_dish_flavor 已经出现在“代码生成”的主列表中。此时,它们还只是两张独立的、互不相干的表。我们的下一步,就是 定义它们之间的主子关系,在这之后我们在菜单栏目新增一个目录项用于存放我们的业务模块

img


8.2.3. 代码生成器:配置主子表关联

这是最关键的一步。我们将在这里把“设计图纸”的所有细节“告知”代码生成器,包括:

  • tb_dish 是主表。
  • tb_dish_flavor 是子表。
  • 它们通过 dish_id 关联。
  • 生成的代码应放入我们新建的 prorise-merchant 模块。

实战操作:

  1. 在“代码生成”主列表中,找到 tb_dish主表)这一行,点击右侧的 “编辑” 按钮。
  2. 编辑界面包含三个核心选项卡,我们逐一配置:

选项卡一:基本信息 (Gen Info)

  • 生成模板(核心) 必须选择 主子表
  • 生成模块名(核心) 这是决定代码存放位置的关键。必须修改为我们 8.1.6 节创建的模块名:merchant
  • 生成业务名dish。这将决定生成的 Controller 路径(/merchant/dish)和权限标识(merchant:dish:list)。
  • 生成包路径com.prorise.merchant
  • 功能名称:“菜品管理”。

img

选项卡二:字段信息 (Column Info)

此选项卡用于配置每个字段在页面上的表现形式。

  1. 我们需要给 status (售卖状态) 字段定义数据字典
  2. 字典类型:去“系统管理 -> 字典管理”中创建一个新的字典 dish_status0=停售, 1=起售),然后再回来选择。

img

我们可以回到菜品管理表中勾选如下的数据生成表单:

img

此时,我们已经完成了所有的准备工作。若依的代码生成器已经充分理解了 tb_dishtb_dish_flavor 之间的主子关系以及我们所有的定制化需求。

在我们完成代码生成后即可预览到如下的页面:

img


8.3. 主子表改造(一):后端查询与业务

8.3.1. 需求:列表聚合子表数量(JOIN 查询)

提出新需求
学员

我集成了代码,并通过“菜单管理”配置了“菜品管理”菜单,页面可以访问了!

很好。但请观察“菜品管理”的列表页。你是否发现它缺少了什么关键信息?

学员

列表页只显示了 tb_dish 表的字段(名称、价格、状态等)。

学员

但我无法从列表上直观地看出“干锅牛蛙”这道菜,到底关联了 多少种“口味”(tb_dish_flavor 子表数据)。

完全正确。这就是代码生成器的局限性。它生成的标准列表查询 默认只查询主表

我们的第一个改造点(痛点),就是要让列表页能 聚合显示子表的数量

目标:修改后端查询逻辑,使“菜品管理”列表页在返回主表 Dish 数据时,额外返回一个 flavorCount(口味数量)字段。

当前的问题

代码生成器生成的 DishMapper.javaDishMapper.xml 中的 selectTbDishList 方法,其 SQL 语句大致如下:

1
2
3
4
5
6
7
8
9
10
 <sql id="selectTbDishVo">
select id, name, price, image, description, status, create_time, update_time from tb_dish
</sql>
<select id="selectTbDishList" parameterType="TbDish" resultMap="TbDishResult">
<include refid="selectTbDishVo"/>
<where>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="status != null "> and status = #{status}</if>
</where>
</select>

这个查询完全没有触碰 tb_dish_flavor 表,因此无法获取到任何子表信息。

我们的解决方案

我们将通过以下步骤,对后端代码进行第一次“改造”:

  1. DTO 扩展 (8.3.2):在 Dish 实体类中增加一个非数据库映射的 flavorCount 字段,用于承载计数值。
  2. Mapper 修改 (8.3.2):修改 DishMapper.xml 中的 selectDishList 查询。我们将使用 LEFT JOINCOUNT(DISTINCT ...) 来统计每个 dish 关联的 flavor 数量。

下一节,我们将开始动手实现这个改造。


8.3.2. 实体类 (DTO) 扩展:增加 flavorCount 字段

改造第一步:扩展数据容器

我们 8.3.1 节的目标是“在列表页显示子表数量”。

我们的后端 TbDishController 查询列表时,最终返回的是 List<TbDish>

那么,这个“口味数量” (flavorCount) 必须要有地方承载。我们首先要做的,就是在 TbDish.java 这个“数据容器”中,预留一个“空位”来存放它。

目标:修改 TbDish.java 实体类,增加一个用于承载“口味数量”的 flavorCount 字段。

这个字段是一个“幽灵”字段(非数据库表字段)。它的作用是作为数据传输对象(DTO)的一部分,从 Mapper 层接收聚合统计的结果,一路传递到 Controller,并最终序列化为 JSON 返回给前端。

文件路径prorise-merchant/src/main/java/com/prorise/merchant/domain/TbDish.java

渐进式修改

我们打开 TbDish.java 文件。

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
/* TbDish.java (原始) */
package com.prorise.merchant.domain;

import java.math.BigDecimal;
import com.prorise.common.annotation.Excel;
import com.prorise.common.core.domain.BaseEntity;

public class TbDish extends BaseEntity
{
private static final long serialVersionUID = 1L;

/** 主键 */
private Long id;

/** 菜品名称 */
@Excel(name = "菜品名称")
private String name;

/* ... 省略 price, image, description, status ... */

/** 菜品口味关系信息 */
private List<TbDishFlavor> tbDishFlavorList; // <-- 这是主子表关联的关键

// ... 省略 getter 和 setter ...
}

现在,我们在 tbDishFlavorList 字段下方,添加新的 flavorCount 字段,并为其生成 gettersetter 方法。

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
/* TbDish.java (修改后) */
package com.prorise.merchant.domain;

import java.math.BigDecimal;
import java.util.List; // 确保 List 已导入
import com.prorise.common.annotation.Excel;
import com.prorise.common.core.domain.BaseEntity;

public class TbDish extends BaseEntity
{
private static final long serialVersionUID = 1L;

/** 主键 */
private Long id;

/** 菜品名称 */
@Excel(name = "菜品名称")
private String name;

/* ... 省略 price, image, description, status ... */

/** 菜品口味关系信息 */
private List<TbDishFlavor> tbDishFlavorList;

/** (新增) 口味数量 (非数据库字段,用于 JOIN 统计) */
private Integer flavorCount;

// ... 省略 id, name, price 等的 getter/setter ...

/** (新增) flavorCount 的 getter 和 setter */
public void setFlavorCount(Integer flavorCount)
{
this.flavorCount = flavorCount;
}

public Integer getFlavorCount()
{
return flavorCount;
}

// ... 省略 toString() ...
}
  • 我们使用 Integer 而不是 int,是为了保持和其他实体类字段(如 Long)的一致性,允许其值为 null
  • 切勿 在此字段上添加 @Excel 注解,因为 flavorCount 并不在数据库的 tb_dish 表中,它只是一个用于数据传输的临时载体。

8.3.3. ResultMap 映射:关联新 flavorCount 字段

在 8.3.2 节中,我们的 TbDish 类(“数据容器”)已经准备好了。但 MyBatis 并不知道如何将数据库查询出的 count 值放入这个新字段中。

目标:修改 TbDishMapper.xml 中的 TbDishResult(ResultMap),建立数据库查询列 flavor_count 与实体类字段 flavorCount 之间的映射关系。

文件路径prorise-merchant/src/main/resources/mapper/merchant/TbDishMapper.xml

渐进式修改

我们打开 TbDishMapper.xml 文件,找到 TbDishResult 的定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<mapper namespace="com.prorise.merchant.mapper.TbDishMapper">

<resultMap type="TbDish" id="TbDishResult">
<result property="id" column="id" />
<result property="name" column="name" />
<result property="price" column="price" />
<result property="image" column="image" />
<result property="description" column="description" />
<result property="status" column="status" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>

</mapper>

我们需要在这个映射关系中,新增一行,告诉 MyBatis:

  • property = “flavorCount”:对应 TbDish.java 中的 flavorCount 字段。
  • column =" flavor_count ":对应我们将在 8.3.4 节的 SQL 查询中 AS 出来的 flavor_count 列名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<mapper namespace="com.prorise.merchant.mapper.TbDishMapper">

<resultMap type="TbDish" id="TbDishResult">
<result property="id" column="id" />
<result property="name" column="name" />
<result property="price" column="price" />
<result property="image" column="image" />
<result property="description" column="description" />
<result property="status" column="status" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />

<result property="flavorCount" column="flavor_count" />
</resultMap>

</mapper>

8.3.4. SQL 改造:使用 JOIN 聚合查询

现在,万事俱备。TbDish.java(容器)和 TbDishResult(映射器)都已准备就绪。我们终于可以修改 SQL 语句,真正地从数据库中抓取“口味数量”了。

目标:修改 TbDishMapper.xml 中的 selectTbDishVo SQL 片段,使用 LEFT JOIN 联表查询,并通过 COUNTGROUP BY 统计出每个菜品关联的口味数量。

文件路径prorise-merchant/src/main/resources/mapper/merchant/TbDishMapper.xml

渐进式修改

我们定位到 <sql id="selectTbDishVo">

1
2
3
<sql id="selectTbDishVo">
select id, name, price, image, description, status, create_time, update_time from tb_dish
</sql>

这个查询非常简单,只查询了 tb_dish 单表。我们需要将其改造为联表查询:

  1. select ...:在查询列中增加 COUNT(f.id) as flavor_count
  2. from tb_dish d:为主表 tb_dish 设置别名 d
  3. left join ...LEFT JOIN tb_dish_flavor 表(别名为 f),关联条件是 d.id = f.dish_id
  4. group by ...:必须按主表 id 分组(GROUP BY d.id),COUNT 函数才能正确统计 每个 菜品的口味数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<sql id="selectTbDishVo">
select
d.id,
d.name,
d.price,
d.image,
d.description,
d.status,
d.create_time,
d.update_time,

COUNT(f.id) as flavor_count

from tb_dish d

left join tb_dish_flavor f on d.id = f.dish_id
</sql>

关键的 selectTbDishList 调整

虽然我们修改了 selectTbDishVo,但引用它的 selectTbDishList 查询也必须调整。

1
2
3
4
5
6
7
<select id="selectTbDishList" parameterType="TbDish" resultMap="TbDishResult">
<include refid="selectTbDishVo"/>
<where>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="status != null "> and status = #{status}</if>
</where>
</select>

因为 selectTbDishVo 中引入了聚合函数 COUNT(),我们就必须添加 GROUP BY 子句。同时,WHERE 条件中的字段名也需要加上别名 d. 以避免歧义。

1
2
3
4
5
6
7
8
9
<select id="selectTbDishList" parameterType="TbDish" resultMap="TbDishResult">
<include refid="selectTbDishVo"/>
<where>
<if test="name != null and name != ''"> and d.name like concat('%', #{name}, '%')</if>
<if test="status != null "> and d.status = #{status}</if>
</where>

group by d.id
</select>

后端改造完成

至此,后端改造已全部完成。重启后端服务后,调用 /merchant/dish/list 接口,您将在返回的 JSON 数据中看到 flavorCount 字段。


8.3.5. 前端改造:index.vue 显示口味数量

闭合功能环路

在 8.3.2 到 8.3.4 节中,我们已经成功改造了后端。

现在,listDish 接口 (/merchant/dish/list) 返回的 JSON 列表中,每一行 TbDish 数据都包含了一个新字段:flavorCount

学员

但是我在 index.vue 列表页上并没有看到它。

完全正确。后端提供了数据,但前端(index.vue)尚未配置“消费”这个数据。

我们的最后一步,就是在 el-table 中添加一个新列,将这个 flavorCount 字段展示给用户。

目标:修改 index.vue,在表格中添加“口味数量”列。

文件路径ruoyi-ui/src/views/merchant/dish/index.vue

渐进式修改

我们打开代码生成器提供的 index.vue 文件,定位到 <el-table> 组件部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<el-table v-loading="loading" :data="dishList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="主键" align="center" prop="id" />
<el-table-column label="菜品名称" align="center" prop="name" />
<el-table-column label="菜品价格" align="center" prop="price" />
<el-table-column label="图片" align="center" prop="image" width="100">
</el-table-column>
<el-table-column label="是否上架" align="center" prop="status" >
</el-table-column>
<el-table-column label="更新时间" align="center" prop="updateTime" width="180">
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
</el-table-column>
</el-table>

我们将在“菜品价格”列和“图片”列之间,插入一个新列,用于显示 flavorCount

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<el-table v-loading="loading" :data="dishList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="主键" align="center" prop="id" />
<el-table-column label="菜品名称" align="center" prop="name" />
<el-table-column label="菜品价格" align="center" prop="price" />

<el-table-column label="口味数量" align="center" prop="flavorCount" />

<el-table-column label="图片" align="center" prop="image" width="100">
<template #default="scope">
<image-preview :src="scope.row.image" :width="50" :height="50" />
</template>
</el-table-column>
<el-table-column label="是否上架" align="center" prop="status" >
</el-table-column>
<el-table-column label="更新时间" align="center" prop="updateTime" width="180">
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
</el-table-column>
</el-table>

验证结果

保存文件(Vite 会自动热更新)。刷新“菜品管理”页面,您会看到“口味数量”列已经出现,并正确显示了每个菜品关联的子表条目数。

img


8.4. 主子表改造(二):前端交互优化

8.4.1. 痛点分析:子表“口味”的低效交互

发现新痛点

列表页的聚合查询功能(8.3)已经完成。现在,我们把注意力转移到“新增”和“修改”功能上。

请点击“新增”按钮,查看“菜品口味关系信息”这个子表。

学员

我看到了。它允许我添加一行,然后手动输入“口味名称”和“口味列表”。

这正是问题所在。这种依赖“手动输入”的交互方式,存在三个致命缺陷。

我们检视 index.vue (L115 - L125) 中子表 el-table 的定义:

1
2
3
4
5
6
7
8
9
10
<el-table-column label="口味名称 (如: 辣度)" prop="name" width="150">
<template #default="scope">
<el-input v-model="scope.row.name" placeholder="请输入口味名称 (如: 辣度)" />
</template>
</el-table-column>
<el-table-column label="口味列表 (如: 微辣,中辣)" prop="value" width="150">
<template #default="scope">
<el-input v-model="scope.row.value" placeholder="请输入口味列表 (如: 微辣,中辣)" />
</template>
</el-table-column>

交互缺陷分析

  1. 易出错的数据 (name):用户在“口味名称”列必须 手动 输入 “辣度”、“忌口”、“甜度”。这极易导致数据不一致(例如,有人输入“辣度”,有人输入“辣”)。
  2. 极差的体验 (value)value 列要求用户手动输入一个 JSON 数组字符串(如 ["微辣","中辣"])。这不仅强人所难,而且 100% 会出错。
  3. 数据不规范:没有统一的数据源,导致数据库 tb_dish_flavor 中充满了各种不规范的脏数据。

我们的解决方案

我们将彻底抛弃 el-input,使用 el-select(下拉框)来重构这个交互:

  1. “口味名称” (name):改造为 单选 el-select。数据源来自后端 API,列出所有可用的口味类型(如 “辣度”, “忌口”, “甜度”)。
  2. “口味列表” (value):改造为 多选 el-select。数据源根据“口味名称”的选择 动态联动 变化(例如,选择“辣度”后,这里显示 “微辣”, “中辣”, “重辣”)。
  3. 交互优化:“口味列表”在“口味名称”被选择之前,应处于 禁用disabled)状态。

要实现这个方案,第一步就是需要后端提供一个 API,告诉我们有哪些“口味名称”及其对应的“口味列表”选项。


8.4.2. 后端 API 扩展:提供“口味”数据源

目标:在 TbDishController 中创建一个新的 GET 接口,例如 /merchant/dish/flavors,用于返回一个去重后的、包含所有可用“口味名称”及其“口味列表”的数据。

查询策略

我们 tb_dish_flavor 表中存储的数据是 (dish_id, name, value)namevalue 是多对多的关系,但 namevalue 的组合(如 “辣度” -> ["微辣", "中辣"])在业务上应该是 唯一 的。

当你在 SQL 中使用 GROUP BY name 时,你的意图是:将 tb_dish_flavor 表中所有 name 相同的行合并成一个结果行。例如,所有 name 为 “辣度” 的行合并成一行,所有 name 为 “忌口” 的行合并成另一行。

现在,对于 “辣度” 这个分组,我们需要从中选出一个 value。因为业务假设告诉我们,这个分组里所有的 value 字符串 ‘[“微辣”, “中辣”, “特辣”]’ 都是 完全相同 的。

因此,我们可以使用 GROUP BY name 配合 MAX(value)(或 MIN(value))来获取每种 name 对应的 value 列表。

  • MAX(‘[“微辣”, “中辣”, “特辣”]’, ‘[“微辣”, “中辣”, “特辣”]’, …) 的结果依然是 ‘[“微辣”, “中辣”, “特辣”]’。
  • MIN(‘[“微辣”, “中辣”, “特辣”]’, ‘[“微辣”, “中辣”, “特辣”]’, …) 的结果也依然是 ‘[“微辣”, “中辣”, “特辣”]’。

步骤一:TbDishMapper.xml 添加 SQL 查询

文件路径prorise-merchant/src/main/resources/mapper/merchant/TbDishMapper.xml

</mapper> 标签前,添加一个新的 <select> 节点。

1
2
3
4
5
6
7
8
<select id="selectDishFlavorTypes" resultMap="TbDishFlavorResult">
select
name,
MAX(value) as value
from tb_dish_flavor
where name is not null and value is not null
group by name
</select>
  • 我们复用了 TbDishFlavorResult 这个 ResultMap
  • MAX(value) 是一种 SQL 技巧,用于 GROUP BY 时确保 value 字段能被带出。这基于一个业务假设:即所有同名(如 “辣度”)的 value 都是相同的 JSON 字符串。
  • where 条件用于排除脏数据。

步骤二:TbDishMapper.java 添加接口方法

文件路径prorise-merchant/src/main/java/com/prorise/merchant/mapper/TbDishMapper.java

TbDishMapper 接口中,添加与 XML ID 对应的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* TbDishMapper.java (新增) */
// ... (省略其他 import) ...
import com.prorise.merchant.domain.TbDishFlavor;

public interface TbDishMapper
{
// ... (省略其他方法) ...

/**
* 批量新增菜品口味关系
*/
public int batchTbDishFlavor(List<TbDishFlavor> tbDishFlavorList);

/**
* (新增) 查询所有唯一的口味类型
* @return 菜品口味关系集合
*/
public List<TbDishFlavor> selectDishFlavorTypes();
}

步骤三:ITbDishService.java 添加接口方法

文件路径prorise-merchant/src/main/java/com/prorise/merchant/service/ITbDishService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* ITbDishService.java (新增) */
// ... (省略其他 import) ...
import com.prorise.merchant.domain.TbDishFlavor;

public interface ITbDishService
{
// ... (省略其他方法) ...

/**
* 删除菜品管理信息
*/
public int deleteTbDishById(Long id);

/**
* (新增) 查询所有唯一的口味类型
* @return 菜品口味关系集合
*/
public List<TbDishFlavor> selectDishFlavorTypes();
}

步骤四:TbDishServiceImpl.java 实现方法

文件路径prorise-merchant/src/main/java/com/prorise/merchant/service/impl/TbDishServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* TbDishServiceImpl.java (新增) */
@Service
public class TbDishServiceImpl implements ITbDishService
{
@Autowired
private TbDishMapper tbDishMapper;

// ... (省略 selectTbDishById, selectTbDishList 等...)

/**
* (新增) 查询所有唯一的口味类型
* * @return 菜品口味关系集合
*/
@Override
public List<TbDishFlavor> selectDishFlavorTypes()
{
return tbDishMapper.selectDishFlavorTypes();
}
}

步骤五:TbDishController.java 暴露 API 接口

文件路径prorise-merchant/src/main/java/com/prorise/merchant/controller/TbDishController.java

list() 方法下方,添加新的 getFlavorTypes() 方法。

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
/* TbDishController.java (新增) */
@RestController
@RequestMapping("/merchant/dish")
public class TbDishController extends BaseController
{
@Autowired
private ITbDishService tbDishService;

/**
* 查询菜品管理列表
*/
@PreAuthorize("@ss.hasPermi('merchant:dish:list')")
@GetMapping("/list")
public TableDataInfo list(TbDish tbDish)
{
// ...
}

/**
* (新增) 获取所有菜品口味类型
*/
@PreAuthorize("@ss.hasPermi('merchant:dish:list')")
@GetMapping(value = "/flavors")
public AjaxResult getFlavorTypes()
{
return success(tbDishService.selectDishFlavorTypes());
}

/**
* 导出菜品管理列表
*/
// ... (省略 export, getInfo, add, edit, remove ...)
}

Prorise 笔记

  • 我们复用了 'merchant:dish:list' 权限,因为获取口味数据通常被视为“查询列表”的一部分。
  • GET /merchant/dish/flavors 这个新 API 现在已经可用。

后端改造完成

重启后端服务。现在,访问 http://localhost:8080/merchant/dish/flavors(需要携带 Token),您应该能获取到类似如下的 JSON 数据,这正是前端实现联动下拉框所需的数据源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"code": 200,
"msg": "操作成功",
"data": [
{
"name": "忌口",
"value": "[\"不要葱\",\"不要蒜\",\"不要香菜\",\"不要辣\"]"
},
{
"name": "辣度",
"value": "[\"不辣\",\"微辣\",\"中辣\",\"重辣\"]"
},
{
"name": "甜味",
"value": "[\"无糖\",\"少糖\",\"半糖\"]"
}
]
}

8.4.3. dish.js 扩展:定义 API 接口

前端接入点

我们的后端在 8.4.2 节已经准备好了 /merchant/dish/flavors 接口,它能返回所有可用的口味数据。

现在,前端需要一个“插头”去连接这个接口。

学员

我猜是在 api/merchant/dish.js 文件里添加一个新函数?

非常准确。api 目录下的文件是前端 Vue 视图与后端 Controller 之间的“连接器”。我们将在这里添加一个 getFlavorTypes 函数,来封装对该接口的 GET 请求。

目标:修改 api/merchant/dish.js 文件,添加一个新函数 getFlavorTypes,用于调用 8.4.2 节创建的后端 API。

文件路径ruoyi-ui/src/api/merchant/dish.js

渐进式修改

我们打开代码生成器提供的 dish.js 文件,在文件末尾添加一个新函数。

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
/* dish.js (修改后) */
import request from '@/utils/request'

// 查询菜品管理列表
export function listDish(query) {
return request({
url: '/merchant/dish/list',
method: 'get',
params: query
})
}

// ... (省略 getDish, addDish, updateDish, delDish) ...

// 删除菜品管理
export function delDish(id) {
return request({
url: '/merchant/dish/' + id,
method: 'delete'
})
}

// 新增:获取所有菜品口味类型
export function getFlavorTypes() {
return request({
url: '/merchant/dish/flavors',
method: 'get'
})
}

8.4.4. index.vue 脚本:获取并存储口味数据源

现在,我们的 api 文件已经有了 getFlavorTypes 函数。下一步是在 index.vue 页面组件加载时,调用这个函数,获取数据,并将其存储在一个响应式变量中,以便 <template> 模板可以使用。

目标:修改 index.vue<script setup> 部分,在页面加载时获取口味数据源,并存入 ref

文件路径ruoyi-ui/src/views/merchant/dish/index.vue

1. 导入新 API

<script setup> 顶部,import 区域添加 getFlavorTypes

1
2
3
4
5
6
7
8
9
<script setup name="Dish">
/* 修改:
从 "@/api/merchant/dish" 导入 getFlavorTypes
*/
import { listDish, getDish, delDish, addDish, updateDish, getFlavorTypes } from "@/api/merchant/dish"

const { proxy } = getCurrentInstance()
// ...
</script>

2. 创建 ref 存储数据源

ref 定义区域,添加一个新 ref 用于存储从 API 获取的口味选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup name="Dish">
// ...
const { dish_status } = proxy.useDict('dish_status')

const dishList = ref([])
const tbDishFlavorList = ref([])
// ...
const title = ref("")

// 新增:创建一个 ref 来存储口味数据源
const flavorOptions = ref([])

const data = reactive({
// ...
})
// ...
</script>

3. 添加函数以加载数据

我们创建一个新函数 loadFlavorOptions,用于调用 API 并将结果(即 8.4.2 节末尾展示的 JSON data 数组)赋给 flavorOptions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup name="Dish">
// ...
const { queryParams, form, rules } = toRefs(data)

/** (新增) 加载口味数据源 */
function loadFlavorOptions() {
getFlavorTypes().then(response => {
// 将后端返回的 {name, value} 数组存入 ref
flavorOptions.value = response.data
})
}

/** 查询菜品管理列表 */
function getList() {
// ...
}
// ...
</script>

4. 在页面加载时调用

最后,我们需要在页面初始化时执行 loadFlavorOptions。最佳位置是在 onMounted 钩子中,或者(由于 getList 已经在 onMounted 时执行了)我们可以在 getList() 被调用时一起调用它。

为保持逻辑清晰,我们统一在 getList() 之后调用。

1
2
3
4
5
6
7
8
9
10
11
<script setup name="Dish">
// ...

/** 导出按钮操作 */
function handleExport() {
// ...
}

getList()
loadFlavorOptions() // 新增调用:在页面首次加载时获取口味数据
</script>

loadFlavorOptions() 仅需在页面加载时调用一次,因为“口味类型”(如辣度、忌口)是相对固定的,不需要在每次 getList()(例如搜索、翻页)时都重新加载。


8.4.5. index.vue 模板:改造“口味名称”为下拉框

我们已经成功将口味数据源(flavorOptions)加载到了 index.vue 中。现在是时候改造 <template>,将“口味名称”列的 el-input 替换为 el-select 了。

目标:将子表 el-table 中“口味名称”列的 el-input 替换为 el-select,并使用 flavorOptions 作为数据源。

文件路径ruoyi-ui/src/views/merchant/dish/index.vue

渐进式修改

定位到 el-dialog 中子表 el-table (L115 附近) 的定义。

1
2
3
4
5
6
7
8
9
10
<el-table :data="tbDishFlavorList" ...>
<el-table-column label="口味名称 (如: 辣度)" prop="name" width="150">
<template #default="scope">
<el-input v-model="scope.row.name" placeholder="请输入口味名称 (如: 辣度)" />
</template>
</el-table-column>

<el-table-column label="口味列表 (如: 微辣,中辣)" prop="value" width="150">
</el-table-column>
</el-table>

我们将 el-input 替换为 el-select,并动态绑定数据源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<el-table :data="tbDishFlavorList" ...>
<el-table-column label="口味名称" prop="name" width="150">
<template #default="scope">
<el-select v-model="scope.row.name" placeholder="请选择口味名称">
<el-option
v-for="item in flavorOptions"
:key="item.name"
:label="item.name"
:value="item.name"
></el-option>
</el-select>
</template>
</el-table-column>

<el-table-column label="口味列表" prop="value" width="150">
<template #default="scope">
<el-input v-model="scope.row.value" placeholder="请输入口味列表 (如: 微辣,中辣)" />
</template>
</el-table-column>
</el-table>

验证结果

保存文件并刷新页面。点击“新增”或“修改”按钮,在子表(菜品口味关系)中点击“添加”行。

现在,“口味名称”列不再是输入框,而是一个 下拉选择框,选项中包含了我们从后端 flavors 接口获取的“忌口”、“辣度”、“甜味”等。

img


8.4.6. index.vue 脚本:数据预处理与联动逻辑

改造第二步:准备“联动”逻辑

在 8.4.5 节,我们完成了“口味名称”的下拉框。但我们 8.4.4 节从后端获取的 flavorOptions 数据,其 value 字段是 JSON 字符串,例如 value: "[\"微辣\",\"中辣\"]"

前端的 el-select 下拉框无法直接使用这种字符串。我们需要在 script 中预先处理这些数据。

学员

也就是在 loadFlavorOptions 函数中,拿到数据后,立刻用 JSON.parse 把它转成真正的数组?

完全正确。这就是数据预处理。

另外,当用户从“辣度”切换到“忌口”时,原先选中的值(如 ["微辣"])必须被清空,否则会提交脏数据。我们需要一个 @change 事件处理器来完成这个“重置”操作。

目标:修改 index.vue<script setup>,实现两个关键功能:

  1. loadFlavorOptions 函数中,将 value 字符串预先解析为 value 数组。
  2. 创建一个新的 handleFlavorNameChange 函数,用于在“口味名称”切换时,清空“口味列表”的 v-model 值。

文件路径ruoyi-ui/src/views/merchant/dish/index.vue

1. 修改 loadFlavorOptions (数据预处理)

我们找到 8.4.4 节创建的 loadFlavorOptions 函数。

1
2
3
4
5
6
7
8
9
/* index.vue (script - 原始) */

/** (原始) 加载口味数据源 */
function loadFlavorOptions() {
getFlavorTypes().then(response => {
// 原始做法:直接赋值
flavorOptions.value = response.data
})
}

我们将其修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* index.vue (script - 修改后) */

/** (修改后) 加载口味数据源,并预处理 value 字段 */
function loadFlavorOptions() {
getFlavorTypes().then(response => {
const options = response.data
// 关键:遍历数据源,提前将 JSON 字符串解析为数组
if (options && options.length > 0) {
options.forEach(item => {
// 确保 value 存在且是字符串,防止解析 null 或 undefined 出错
if (item.value && typeof item.value === 'string') {
item.value = JSON.parse(item.value)
} else {
item.value = [] // 容错处理,确保 value 始终是一个数组
}
})
flavorOptions.value = options
}
})
}

Prorise 笔记:经过这个修改,flavorOptions.value 中存储的数据结构变为:[{ name: "辣度", value: ["微辣", "中辣"] }, { name: "忌口", value: ["不要葱"] }]
这是一个干净、可直接用于前端渲染的数组。

2. 新增 handleFlavorNameChange (清空逻辑)

script 区域(例如 getList 函数下方),添加这个新的事件处理器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* index.vue (script - 新增) */
// ...
/** 查询菜品管理列表 */
function getList() {
// ...
}

/** (新增) “口味名称”下拉框 change 事件处理器 */
function handleFlavorNameChange(row) {
// 当“口味名称”改变时,重置“口味列表”的选中值
// `row` 是 el-table 传递的当前行数据对象
row.value = []
}
// ...

8.4.7. index.vue 模板:改造“口味列表”为联动下拉框

现在,script 已经准备好 flavorOptions (预处理数据) 和 handleFlavorNameChange (清空函数)。我们可以开始改造 <template>,完成最后的前端交互。

目标

  1. 将“口味列表”列的 el-input 替换为 多选 el-select
  2. 为“口味名称”的 el-select 绑定 handleFlavorNameChange 事件。
  3. 实现“口味列表”的 数据源联动禁用状态联动

文件路径ruoyi-ui/src/views/merchant/dish/index.vue

渐进式修改

我们定位到 el-dialog 中子表 el-table (L115 附近) 的定义。

1. 为“口味名称”添加 @change 事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<el-table-column label="口味名称" prop="name" width="150">
<template #default="scope">
<el-select
v-model="scope.row.name"
placeholder="请选择口味名称"
@change="handleFlavorNameChange(scope.row)"
>
<el-option
v-for="item in flavorOptions"
:key="item.name"
:label="item.name"
:value="item.name"
></el-option>
</el-select>
</template>
</el-table-column>

2. 将“口味列表”改造为 el-select

这是最关键的改造。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<el-table-column label="口味列表" prop="value" width="150">
<template #default="scope">
<el-select
v-model="scope.row.value"
placeholder="请选择口味"
multiple
collapse-tags
collapse-tags-tooltip
:disabled="!scope.row.name"
clearable
style="width: 100%"
>
<el-option
v-for="option in (flavorOptions.find(f => f.name === scope.row.name) || {}).value"
:key="option"
:label="option"
:value="option"
/>
</el-select>
</template>
</el-table-column>

验证结果

刷新页面,点击“新增”并添加一行口味。

  1. “口味列表”下拉框初始为禁用状态。
  2. 在“口味名称”中选择“辣度”。
  3. “口味列表”下拉框变为可用,点击展开,选项为 ["不辣", "微辣", "中辣", "重辣"]
  4. 在“口味名称”中切换为“忌口”。
  5. “口味列表”的选中值被自动清空(handleFlavorNameChange 生效),下拉框选项变为 ["不要葱", "不要蒜", ...]

8.4.8. index.vue 脚本:修正数据提交格式

发现最后一个“坑”

我们的前端交互现在非常完美。但是,如果你现在点击“确定”提交,会发生什么?

学员

后端会报错。我记得 8.4.2 节提到,tb_dish_flavor.value 字段在数据库中是 varchar,它期望的是 JSON 字符串,比如 "[\"微辣\"]"

没错。但我们 8.4.7 节的 el-select multiple 提交给 v-model (即 scope.row.value) 的是一个 数组,比如 ["微辣"]

我们必须在提交(submitForm)的最后关头,将这个 数组 转换回 JSON 字符串

目标:修改 submitForm 函数,在提交数据前,对 tbDishFlavorList 中的 value 字段进行 JSON.stringify 处理。

关键:我们 不能 直接修改 tbDishFlavorList.value 这个 ref

  • 为什么不行?tbDishFlavorList.value 正被 el-table 渲染。如果我们 JSON.stringify 了它的 valueel-selectv-model 会收到一个字符串而不是数组,多选框会立刻“失控”并崩溃。
  • 正确做法:创建一个 深拷贝 (Deep Copy) 的副本用于提交,在副本上进行修改。

文件路径ruoyi-ui/src/views/merchant/dish/index.vue

渐进式修改

我们找到 submitForm 函数。

我们按“深拷贝 -> 修改副本 -> 提交副本”的思路进行修改:

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
/* index.vue (script - 修改后 submitForm) */

/** 提交按钮 */
function submitForm() {
proxy.$refs["dishRef"].validate(valid => {
if (valid) {

// 1. (新增) 创建一个用于提交的深拷贝副本
// 我们不能污染 `form.value` 和 `tbDishFlavorList.value` 这两个 ref
const submitData = JSON.parse(JSON.stringify(form.value))

// 2. (修改) 将子表数据 (ref) 赋给副本
submitData.tbDishFlavorList = JSON.parse(JSON.stringify(tbDishFlavorList.value))

// 3. (新增) 遍历副本的子表,将 value 数组转换为 JSON 字符串
if (submitData.tbDishFlavorList && submitData.tbDishFlavorList.length > 0) {
submitData.tbDishFlavorList.forEach(item => {
// 确保 value 是数组才转换
if (Array.isArray(item.value)) {
item.value = JSON.stringify(item.value)
}
})
}

if (submitData.id != null) {
// 4. (修改) 提交副本 submitData
updateDish(submitData).then(response => {
proxy.$modal.msgSuccess("修改成功")
open.value = false
getList()
})
} else {
// 5. (修改) 提交副本 submitData
addDish(submitData).then(response => {
proxy.$modal.msgSuccess("新增成功")
open.value = false
getList()
})
}
}
})
}

改造完成

至此,我们完成了“菜品管理”主子表从后端查询优化到前端交互的 全链路深度改造。我们解决了列表页聚合查询、字典驱动 UI、子表 el-input 到联动 el-select、以及最终数据格式回写的 所有痛点