Ruo-Yi基础篇(八):第八章. 进阶实战:若依系统定制、主子表深度改造
Ruo-Yi基础篇(八):第八章. 进阶实战:若依系统定制、主子表深度改造
Prorise第八章. 进阶实战:系统定制、主子表深度改造
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 文件

2. 修改系统标题
系统标题的修改分为两部分:主标题和动态标题。
A. 修改主标题 (index.html)
主标题是 index.html 文件中定义的静态标题,它是整个单页应用(SPA)的“HTML 宿主”标题。
- 文件路径:
index.html(项目根目录)
我们打开此文件,找到 <title> 标签:
1 |
|
我们将其修改为我们自己的企业名称:
1 |
|
B. 修改动态标题 (环境变量)
仅仅修改 index.html 还不够。若依的路由守卫会在页面跳转时,动态地设置浏览器的标题(例如 “首页”、“用户管理”)。这些动态标题的后缀名(即系统总称)是由 环境变量 控制的。
- 文件路径:
.env.development和.env.production(项目根目录)
我们打开这两个文件,修改 VITE_APP_TITLE 变量:
1 | # .env.development (原始) |
修改为:
1 | # .env.development (修改后) |
注意: 务必同时修改 .env.development (开发环境) 和 .env.production (生产环境) 两个文件,确保所有环境下的标题统一。
完成这三步(favicon.ico, index.html, .env)替换后,系统在浏览器标签页的品牌标识就完全统一了。

8.1.3. 登录页改造:重塑系统门面(标题与背景图)
登录页是系统的“门面”,是建立专业形象的关键。
- 文件路径:
src/views/login.vue
我们打开该组件文件,开始进行“装修”。
替换登录页背景图
在 login.vue 文件的 <style> 区域,我们可以找到定义背景图的 CSS。
1 | <style lang='scss' scoped> |
若依将背景图放在了 src/assets/images/login-background.jpg。我们只需将自己准备好的背景图(例如 my-login-bg.png)放入 src/assets/images 目录中,然后修改这里的 url() 路径即可。
1 | <style lang='scss' scoped> |
8.1.4. 布局调整:移除“源码地址”与“文档”链接
完成了 Logo 和标题,我们来处理最明显的“烙印”——顶部导航栏的“源码”和“文档”链接。
在一个正式的交付产品中,保留这些指向外部框架的链接会显得非常不专业,也是品牌化定制必须清除的部分。
它们是硬编码在页面上的吗?
没错。它们是作为独立的组件被直接引入到主导航栏布局中的。我们的任务就是找到这个布局文件,并将它们移除。
1. 定位布局文件
这两个链接是顶部导航栏(Navbar)的一部分,它们被作为子组件引入。
- 文件路径:
src/layout/components/Navbar.vue
2. 渐进式修改
我们打开这个文件,在 <template> 区域的后半部分,会看到控制导航栏右侧菜单项的代码:

1 | <template> |
3. 分析与移除
我们的目标是 <ruoyi-git> 和 <ruoyi-doc> 这两个非标准的 HTML 标签。
<ruoyi-git>:此组件用于渲染指向 RuoYi 源码仓库(Gitee/GitHub)的图标链接。<ruoyi-doc>:此组件用于渲染指向 RuoYi 官方文档的图标链接。
我们的操作非常简单:将这两行组件代码注释掉或直接删除。
1 | <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 的引用,全部替换为我们自定义的新包名。

实战操作:使用修改器
- 准备文件:从若依官网下载最新的后端代码
zip压缩包(例如 https://gitee.com/y_project/RuoYi-Vue/tree/springboot3 ),不要解压。 - 打开工具:运行
RuoYi-MT.jar文件(或其exe版本)。 - 配置参数:参照下图和表格,填写所有字段。
我们以将项目修改为“Prorise 数字化平台”为例,填写配置如下:

| 界面字段 | 示例值 | 解释说明 |
|---|---|---|
| 选择压缩文件 | D:\download\\RuoYi-Vue-springboot3.zip | 选择您刚刚下载的 未解压 的若依后端 zip 包。 |
| 选择系列 | RuoYi-Vue | 必须勾选正确的项目系列(我们使用的是 RuoYi-Vue)。 |
| 目录名称 | Prorise-Digital | 最终生成的 zip 包解压后的根目录名称。 |
| 项目名 | prorise | 通常是公司或项目的英文简称(小写)。 |
| 包名 | com.prorise | (核心) 替换 com.ruoyi 的新包名,通常是反向域名。 |
| artifactId | prorise | Maven 的 artifactId,与项目名保持一致。 |
| groupId | com.prorise | Maven 的 groupId,与包名保持一致。 |
| 站点名称 | Prorise 数字化平台 | 用于替换配置文件中“若依管理系统”的中文显示名称。 |
- 执行:点击 “开始执行” 按钮。工具会在操作记录中显示替换进度,完成后会提示“修改完成”。
- 获取产物:点击 “打开输出目录”,您会找到一个新生成的以时间戳作为命名
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 (后端) 这个父工程下创建子模块。
- 在项目视图中,右键点击
Prorise-Digital父工程的根目录。 - 选择 New -> Module…。
步骤二:配置模块信息
打开 “New Module” 向导后,您会看到一个包含多种选项的界面,按照如下选项填入即可

步骤三:检视初始模块结构
IDEA 最终会创建出正确的模块结构:
1 | Prorise-Digital/ |
同时,IDEA 会自动打开 prorise-merchant 模块下的 pom.xml 文件。请检查该文件,确保 <parent> 标签已正确设置:
1 |
|
步骤四:建立标准业务包结构
模块的骨架有了,我们还需要在 java 目录下建立清晰的包(Package)结构,以便后续存放代码。
- 在
prorise-merchant/src/main/java目录下,创建基础包:com.prorise.merchant。 - 在
com.prorise.merchant包下,创建业务分层包:controller(存放 Spring MVC 控制器)domain(存放实体类 POJO)mapper(存放 MyBatis Mapper 接口)service(存放业务逻辑接口)service.impl(存放业务逻辑实现)
最终的 Java 目录结构如下:
1 | prorise-merchant/ |
我们已经成功创建了独立的 prorise-merchant 模块。目前,该模块在工程结构层面存在两个关键问题:
- 父工程
Prorise-Digital的pom.xml中尚未声明prorise-merchant作为其子模块。 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 |
|
8.1.7. 工程配置:父工程 pom.xml 声明与版本锁定
目标:
- 在父 POM 的
<modules>中声明prorise-merchant。 - 在父 POM 的
<dependencyManagement>中锁定prorise-merchant和prorise-common的版本。
背景:在 8.1.6 节中,我们创建了 prorise-merchant 模块。现在需要在父工程中对其进行“注册”和“管理”。
- 声明 (
<modules>):告知 MavenProrise-Digital是一个多模块项目,prorise-merchant是其子模块之一。 - 版本锁定 (
<dependencyManagement>):统一管理所有模块的版本。在此处声明prorise-merchant的版本后,其他模块(如prorise-admin)在引入它们时,就 无需 指定<version>标签,从而避免了版本冲突。
文件路径:Prorise-Digital/pom.xml (项目根目录的 pom.xml)
步骤一:声明模块
定位到 <modules> 标签,添加 prorise-merchant。
1 | <modules> |
步骤二:版本锁定
定位到 <dependencyManagement> 标签,在其中添加 prorise-merchant 的依赖声明。
1 | <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 | <dependencies> |
我们在此处添加 prorise-merchant 的依赖声明。
1 | <dependencies> |
完成工程配置
保存所有修改过的 pom.xml 文件并刷新 Maven 依赖后,prorise-admin 启动模块现在就与 prorise-merchant 业务模块建立了依赖关系。
至此,新模块的工程配置全部完成,我们已经准备好在 prorise-merchant 模块中开发全新的业务功能了。
8.2. 主子表案例基础(菜品管理)
我们的 prorise-merchant 模块工程已经搭建完毕。现在,我们将开始第一个核心实战:主子表功能。
什么是主子表?
主子表是企业开发中最常见的“一对多”数据模型。以“菜品管理”为例:
“菜品(Dish)”是 主表,它存储菜品的基本信息,如名称、价格、图片。
“口味(Flavor)”是 子表,一个菜品可以对应 多 个口味(如“辣度”、“忌口”)。
我们的目标是,在若依的代码生成器中,实现一个功能:当“新增/修改菜品”时,可以在同一个表单中,动态地添加、删除、修改其关联的“口味”列表。
在本节中,我们将完成实现该功能所需的所有“地基”工作。
8.2.1. 业务分析与数据建模
在启动代码生成器之前,我们必须先完成最关键的第一步:数据建模。代码生成器是“执行者”,而我们是“设计师”,它只能根据我们提供的“图纸”(数据库表结构)来工作。
我们的核心业务需求是“管理菜品”。一个“菜品”对象,包含了两种不同性质的数据:
- 单一属性(一对一):一个菜品只有一个“名称”、一个“价格”、一张“主图”。
- 复合属性(一对多):一个菜品可以拥有 多组“口味”。例如,“干锅牛蛙”这道菜,既有“辣度”属性(可选:微辣、中辣、重辣),又有“忌口”属性(可选:不要葱、不要蒜)。
为了在数据库中精确表达这种“一对多”的关系,我们不能(也绝不应该)在菜品表中设计 flavor1_name、flavor1_value、flavor2_name 这样的字段,这会导致表结构僵化且无法扩展。
标准的解决方案是使用 两张表 来描述这个模型:
- 主表
tb_dish:用于存储菜品的“一对一”基础信息。 - 子表
tb_dish_flavor:用于存储“一对多”的口味信息。
这两张表通过一个“外键”进行关联。子表 tb_dish_flavor 中的 dish_id 字段,将指向它所属的主表 tb_dish 的 id。

实战操作:创建表结构
请在您的 MySQL 数据库(如 prorise_db)中执行以下 SQL 脚本,创建这两张表并插入用于后续开发的演示数据。
1 | -- ---------------------------- |
8.2.2. 代码生成器:导入表结构
现在,我们的“设计图纸”(数据库表)已经就绪。下一步是让若依的“代码生成器”读取这些图纸。
这个“导入”操作,本质上是若依框架在 逆向读取 数据库的 information_schema,获取指定表的元数据(MetaData),并将其存储在 gen_table 和 gen_table_column 这两张表中,以便后续进行更详细的配置。
实战操作:
- 登录若依后台管理界面。
- 导航至 系统工具 -> 代码生成。
- 点击左上角的 “导入” 按钮。
- 系统会弹出一个窗口,显示当前数据库中所有尚未被代码生成器纳管的表。
- 在列表中找到我们刚刚创建的
tb_dish和tb_dish_flavor。 - 勾选 这两张表。
- 点击 “确定” 按钮。
结果验证:
导入成功后,您会看到 tb_dish 和 tb_dish_flavor 已经出现在“代码生成”的主列表中。此时,它们还只是两张独立的、互不相干的表。我们的下一步,就是 定义它们之间的主子关系,在这之后我们在菜单栏目新增一个目录项用于存放我们的业务模块

8.2.3. 代码生成器:配置主子表关联
这是最关键的一步。我们将在这里把“设计图纸”的所有细节“告知”代码生成器,包括:
tb_dish是主表。tb_dish_flavor是子表。- 它们通过
dish_id关联。 - 生成的代码应放入我们新建的
prorise-merchant模块。
实战操作:
- 在“代码生成”主列表中,找到
tb_dish(主表)这一行,点击右侧的 “编辑” 按钮。 - 编辑界面包含三个核心选项卡,我们逐一配置:
选项卡一:基本信息 (Gen Info)
- 生成模板:(核心) 必须选择
主子表。 - 生成模块名:(核心) 这是决定代码存放位置的关键。必须修改为我们 8.1.6 节创建的模块名:
merchant。 - 生成业务名:
dish。这将决定生成的 Controller 路径(/merchant/dish)和权限标识(merchant:dish:list)。 - 生成包路径:
com.prorise.merchant。 - 功能名称:“菜品管理”。

选项卡二:字段信息 (Column Info)
此选项卡用于配置每个字段在页面上的表现形式。
- 我们需要给
status(售卖状态) 字段定义数据字典 - 字典类型:去“系统管理 -> 字典管理”中创建一个新的字典
dish_status(0=停售,1=起售),然后再回来选择。

我们可以回到菜品管理表中勾选如下的数据生成表单:

此时,我们已经完成了所有的准备工作。若依的代码生成器已经充分理解了 tb_dish 和 tb_dish_flavor 之间的主子关系以及我们所有的定制化需求。
在我们完成代码生成后即可预览到如下的页面:

8.3. 主子表改造(一):后端查询与业务
8.3.1. 需求:列表聚合子表数量(JOIN 查询)
我集成了代码,并通过“菜单管理”配置了“菜品管理”菜单,页面可以访问了!
很好。但请观察“菜品管理”的列表页。你是否发现它缺少了什么关键信息?
列表页只显示了 tb_dish 表的字段(名称、价格、状态等)。
但我无法从列表上直观地看出“干锅牛蛙”这道菜,到底关联了 多少种“口味”(tb_dish_flavor 子表数据)。
完全正确。这就是代码生成器的局限性。它生成的标准列表查询 默认只查询主表。
我们的第一个改造点(痛点),就是要让列表页能 聚合显示子表的数量。
目标:修改后端查询逻辑,使“菜品管理”列表页在返回主表 Dish 数据时,额外返回一个 flavorCount(口味数量)字段。
当前的问题
代码生成器生成的 DishMapper.java 和 DishMapper.xml 中的 selectTbDishList 方法,其 SQL 语句大致如下:
1 | <sql id="selectTbDishVo"> |
这个查询完全没有触碰 tb_dish_flavor 表,因此无法获取到任何子表信息。
我们的解决方案
我们将通过以下步骤,对后端代码进行第一次“改造”:
- DTO 扩展 (8.3.2):在
Dish实体类中增加一个非数据库映射的flavorCount字段,用于承载计数值。 - Mapper 修改 (8.3.2):修改
DishMapper.xml中的selectDishList查询。我们将使用LEFT JOIN和COUNT(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 | /* TbDish.java (原始) */ |
现在,我们在 tbDishFlavorList 字段下方,添加新的 flavorCount 字段,并为其生成 getter 和 setter 方法。
1 | /* TbDish.java (修改后) */ |
- 我们使用
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 | <mapper namespace="com.prorise.merchant.mapper.TbDishMapper"> |
我们需要在这个映射关系中,新增一行,告诉 MyBatis:
- property = “flavorCount”:对应
TbDish.java中的flavorCount字段。 - column =" flavor_count ":对应我们将在 8.3.4 节的 SQL 查询中
AS出来的flavor_count列名。
1 | <mapper namespace="com.prorise.merchant.mapper.TbDishMapper"> |
8.3.4. SQL 改造:使用 JOIN 聚合查询
现在,万事俱备。TbDish.java(容器)和 TbDishResult(映射器)都已准备就绪。我们终于可以修改 SQL 语句,真正地从数据库中抓取“口味数量”了。
目标:修改 TbDishMapper.xml 中的 selectTbDishVo SQL 片段,使用 LEFT JOIN 联表查询,并通过 COUNT 和 GROUP BY 统计出每个菜品关联的口味数量。
文件路径:prorise-merchant/src/main/resources/mapper/merchant/TbDishMapper.xml
渐进式修改:
我们定位到 <sql id="selectTbDishVo">:
1 | <sql id="selectTbDishVo"> |
这个查询非常简单,只查询了 tb_dish 单表。我们需要将其改造为联表查询:
select ...:在查询列中增加COUNT(f.id) as flavor_count。from tb_dish d:为主表tb_dish设置别名d。left join ...:LEFT JOINtb_dish_flavor表(别名为f),关联条件是d.id = f.dish_id。group by ...:必须按主表id分组(GROUP BY d.id),COUNT函数才能正确统计 每个 菜品的口味数。
1 | <sql id="selectTbDishVo"> |
关键的 selectTbDishList 调整
虽然我们修改了 selectTbDishVo,但引用它的 selectTbDishList 查询也必须调整。
1 | <select id="selectTbDishList" parameterType="TbDish" resultMap="TbDishResult"> |
因为 selectTbDishVo 中引入了聚合函数 COUNT(),我们就必须添加 GROUP BY 子句。同时,WHERE 条件中的字段名也需要加上别名 d. 以避免歧义。
1 | <select id="selectTbDishList" parameterType="TbDish" resultMap="TbDishResult"> |
后端改造完成
至此,后端改造已全部完成。重启后端服务后,调用 /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 | <el-table v-loading="loading" :data="dishList" @selection-change="handleSelectionChange"> |
我们将在“菜品价格”列和“图片”列之间,插入一个新列,用于显示 flavorCount。
1 | <el-table v-loading="loading" :data="dishList" @selection-change="handleSelectionChange"> |
验证结果
保存文件(Vite 会自动热更新)。刷新“菜品管理”页面,您会看到“口味数量”列已经出现,并正确显示了每个菜品关联的子表条目数。

8.4. 主子表改造(二):前端交互优化
8.4.1. 痛点分析:子表“口味”的低效交互
列表页的聚合查询功能(8.3)已经完成。现在,我们把注意力转移到“新增”和“修改”功能上。
请点击“新增”按钮,查看“菜品口味关系信息”这个子表。
我看到了。它允许我添加一行,然后手动输入“口味名称”和“口味列表”。
这正是问题所在。这种依赖“手动输入”的交互方式,存在三个致命缺陷。
我们检视 index.vue (L115 - L125) 中子表 el-table 的定义:
1 | <el-table-column label="口味名称 (如: 辣度)" prop="name" width="150"> |
交互缺陷分析:
- 易出错的数据 (
name):用户在“口味名称”列必须 手动 输入 “辣度”、“忌口”、“甜度”。这极易导致数据不一致(例如,有人输入“辣度”,有人输入“辣”)。 - 极差的体验 (
value):value列要求用户手动输入一个 JSON 数组字符串(如["微辣","中辣"])。这不仅强人所难,而且 100% 会出错。 - 数据不规范:没有统一的数据源,导致数据库
tb_dish_flavor中充满了各种不规范的脏数据。
我们的解决方案
我们将彻底抛弃 el-input,使用 el-select(下拉框)来重构这个交互:
- “口味名称” (
name):改造为 单选el-select。数据源来自后端 API,列出所有可用的口味类型(如 “辣度”, “忌口”, “甜度”)。 - “口味列表” (
value):改造为 多选el-select。数据源根据“口味名称”的选择 动态联动 变化(例如,选择“辣度”后,这里显示 “微辣”, “中辣”, “重辣”)。 - 交互优化:“口味列表”在“口味名称”被选择之前,应处于 禁用(
disabled)状态。
要实现这个方案,第一步就是需要后端提供一个 API,告诉我们有哪些“口味名称”及其对应的“口味列表”选项。
8.4.2. 后端 API 扩展:提供“口味”数据源
目标:在 TbDishController 中创建一个新的 GET 接口,例如 /merchant/dish/flavors,用于返回一个去重后的、包含所有可用“口味名称”及其“口味列表”的数据。
查询策略
我们 tb_dish_flavor 表中存储的数据是 (dish_id, name, value)。name 和 value 是多对多的关系,但 name 和 value 的组合(如 “辣度” -> ["微辣", "中辣"])在业务上应该是 唯一 的。
当你在 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 | <select id="selectDishFlavorTypes" resultMap="TbDishFlavorResult"> |
- 我们复用了
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 | /* TbDishMapper.java (新增) */ |
步骤三:ITbDishService.java 添加接口方法
文件路径:prorise-merchant/src/main/java/com/prorise/merchant/service/ITbDishService.java
1 | /* ITbDishService.java (新增) */ |
步骤四:TbDishServiceImpl.java 实现方法
文件路径:prorise-merchant/src/main/java/com/prorise/merchant/service/impl/TbDishServiceImpl.java
1 | /* TbDishServiceImpl.java (新增) */ |
步骤五:TbDishController.java 暴露 API 接口
文件路径:prorise-merchant/src/main/java/com/prorise/merchant/controller/TbDishController.java
在 list() 方法下方,添加新的 getFlavorTypes() 方法。
1 | /* TbDishController.java (新增) */ |
Prorise 笔记:
- 我们复用了
'merchant:dish:list'权限,因为获取口味数据通常被视为“查询列表”的一部分。GET /merchant/dish/flavors这个新 API 现在已经可用。
后端改造完成
重启后端服务。现在,访问 http://localhost:8080/merchant/dish/flavors(需要携带 Token),您应该能获取到类似如下的 JSON 数据,这正是前端实现联动下拉框所需的数据源。
1 | { |
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 | /* dish.js (修改后) */ |
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 | <script setup name="Dish"> |
2. 创建 ref 存储数据源
在 ref 定义区域,添加一个新 ref 用于存储从 API 获取的口味选项。
1 | <script setup name="Dish"> |
3. 添加函数以加载数据
我们创建一个新函数 loadFlavorOptions,用于调用 API 并将结果(即 8.4.2 节末尾展示的 JSON data 数组)赋给 flavorOptions。
1 | <script setup name="Dish"> |
4. 在页面加载时调用
最后,我们需要在页面初始化时执行 loadFlavorOptions。最佳位置是在 onMounted 钩子中,或者(由于 getList 已经在 onMounted 时执行了)我们可以在 getList() 被调用时一起调用它。
为保持逻辑清晰,我们统一在 getList() 之后调用。
1 | <script setup name="Dish"> |
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 | <el-table :data="tbDishFlavorList" ...> |
我们将 el-input 替换为 el-select,并动态绑定数据源:
1 | <el-table :data="tbDishFlavorList" ...> |
验证结果
保存文件并刷新页面。点击“新增”或“修改”按钮,在子表(菜品口味关系)中点击“添加”行。
现在,“口味名称”列不再是输入框,而是一个 下拉选择框,选项中包含了我们从后端 flavors 接口获取的“忌口”、“辣度”、“甜味”等。

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>,实现两个关键功能:
- 在
loadFlavorOptions函数中,将value字符串预先解析为value数组。 - 创建一个新的
handleFlavorNameChange函数,用于在“口味名称”切换时,清空“口味列表”的v-model值。
文件路径:ruoyi-ui/src/views/merchant/dish/index.vue
1. 修改 loadFlavorOptions (数据预处理)
我们找到 8.4.4 节创建的 loadFlavorOptions 函数。
1 | /* index.vue (script - 原始) */ |
我们将其修改为:
1 | /* index.vue (script - 修改后) */ |
Prorise 笔记:经过这个修改,
flavorOptions.value中存储的数据结构变为:[{ name: "辣度", value: ["微辣", "中辣"] }, { name: "忌口", value: ["不要葱"] }]
这是一个干净、可直接用于前端渲染的数组。
2. 新增 handleFlavorNameChange (清空逻辑)
在 script 区域(例如 getList 函数下方),添加这个新的事件处理器。
1 | /* index.vue (script - 新增) */ |
8.4.7. index.vue 模板:改造“口味列表”为联动下拉框
现在,script 已经准备好 flavorOptions (预处理数据) 和 handleFlavorNameChange (清空函数)。我们可以开始改造 <template>,完成最后的前端交互。
目标:
- 将“口味列表”列的
el-input替换为 多选el-select。 - 为“口味名称”的
el-select绑定handleFlavorNameChange事件。 - 实现“口味列表”的 数据源联动 和 禁用状态联动。
文件路径:ruoyi-ui/src/views/merchant/dish/index.vue
渐进式修改:
我们定位到 el-dialog 中子表 el-table (L115 附近) 的定义。
1. 为“口味名称”添加 @change 事件
1 | <el-table-column label="口味名称" prop="name" width="150"> |
2. 将“口味列表”改造为 el-select
这是最关键的改造。
1 | <el-table-column label="口味列表" prop="value" width="150"> |
验证结果
刷新页面,点击“新增”并添加一行口味。
- “口味列表”下拉框初始为禁用状态。
- 在“口味名称”中选择“辣度”。
- “口味列表”下拉框变为可用,点击展开,选项为
["不辣", "微辣", "中辣", "重辣"]。 - 在“口味名称”中切换为“忌口”。
- “口味列表”的选中值被自动清空(
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了它的value,el-select的v-model会收到一个字符串而不是数组,多选框会立刻“失控”并崩溃。 - 正确做法:创建一个 深拷贝 (Deep Copy) 的副本用于提交,在副本上进行修改。
文件路径:ruoyi-ui/src/views/merchant/dish/index.vue
渐进式修改:
我们找到 submitForm 函数。
我们按“深拷贝 -> 修改副本 -> 提交副本”的思路进行修改:
1 | /* index.vue (script - 修改后 submitForm) */ |
改造完成
至此,我们完成了“菜品管理”主子表从后端查询优化到前端交互的 全链路深度改造。我们解决了列表页聚合查询、字典驱动 UI、子表 el-input 到联动 el-select、以及最终数据格式回写的 所有痛点。









