Ruo-Yi基础篇(八):第八章. 进阶实战:若依系统定制、主子表深度改造

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

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、以及最终数据格式回写的 所有痛点